Files
GTARH/Backend/server.js
T
oimer 314a98c1ad Ajouter completion des profils nouveaux arrivants et corrections diverses
- Détection des profils incomplets (société/campus/service/date d'entrée/validateur) avec alerte Dashboard et section dédiée dans Équipe
- Formulaire d'édition des collaborateurs (date d'entrée, rôle, validateur N+1, société, campus, service, type de contrat) avec combobox de recherche pour le validateur
- Correction du mapping poste (colonne description au lieu de fonction) et du filtre des validateurs (variantes de rôle: Validateur/Validatrice, Directeur/Directrice, President)
- Fix du proxy Vite vers le backend Docker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 15:37:03 +02:00

7217 lines
326 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cors = require('cors');
const sql = require('mssql'); // ⭐ CHANGEMENT: mssql au lieu de mysql2
const jwt = require('jsonwebtoken');
const PDFDocument = require('pdfkit');
const { ConfidentialClientApplication } = require('@azure/msal-node');
console.log('✅ 2. Modules de base chargés');
require('dotenv').config();
console.log('✅ 3. Dotenv chargé');
// HANDLERS D'ERREURS
process.on('uncaughtException', (error) => {
console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌');
console.error(error);
console.error(error.stack);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌');
console.error('Raison:', reason);
console.error('Promise:', promise);
});
process.on('exit', (code) => {
console.log(`\n⚠️⚠️⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code} ⚠️⚠️⚠️\n`);
});
console.log('✅ 4. Handlers d\'erreurs installés');
// ⭐ IMPORTS WEBHOOKS
try {
console.log('🔄 5. Chargement WebhookManager...');
const WebhookManager = require('./webhook-utils.js');
console.log(' Type de WebhookManager:', typeof WebhookManager);
console.log('🔄 6. Chargement webhook-config...');
const { WEBHOOKS, EVENTS } = require('./webhook-config');
console.log('✅ 7. Webhooks chargés avec succès');
const app = express();
console.log('✅ 8. Express initialisé');
const PORT = process.env.PORT || 3000;
console.log(`✅ 9. Port configuré: ${PORT}`);
// ⭐ INITIALISER LE WEBHOOK MANAGER
console.log('🔄 10. Initialisation WebhookManager...');
const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY);
console.log('✅ 11. WebhookManager créé');
// Middleware
app.use(cors());
app.use(express.json());
console.log('✅ 12. Middlewares installés');
// ⭐ CONFIGURATION SQL SERVER
const dbConfig = {
server: '192.168.0.3',
user: 'gta_app',
password: 'GTA2025!Secure',
database: 'GTA',
port: 1433,
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
console.log('🔄 13. Test connexion SQL Server...');
let pool;
// ⭐ FONCTION D'INITIALISATION DE LA BASE
async function initializeDatabase() {
try {
pool = await sql.connect(dbConfig);
console.log('✅ 14. Connexion SQL Server réussie');
console.log(' Server:', dbConfig.server);
console.log(' User:', dbConfig.user);
console.log(' Database:', dbConfig.database);
} catch (err) {
console.error('❌ 14. ERREUR CONNEXION SQL SERVER:', err.message);
console.error(' Server:', dbConfig.server);
console.error(' User:', dbConfig.user);
console.error(' Database:', dbConfig.database);
throw err;
}
}
// Initialiser la connexion
initializeDatabase().catch(err => {
console.error('❌ Impossible de démarrer le serveur:', err);
process.exit(1);
});
console.log('✅ 15. Pool SQL Server configuré');
// ================================================
// SNAPSHOT QUOTIDIEN — Appelle la Stored Procedure
// ================================================
async function executerSnapshotQuotidien() {
try {
console.log('[SNAPSHOT] Démarrage du snapshot quotidien...');
const result = await pool.request()
.query('EXEC [dbo].[SP_SnapshotQuotidienCompteurs]');
const resume = result.recordset?.[0];
if (resume) {
console.log(`[SNAPSHOT] ✅ Terminé — Date: ${resume.DateSnapshot}, Insérés: ${resume.NbInseres}`);
} else {
console.log('[SNAPSHOT] ✅ Terminé');
}
} catch (error) {
console.error('[SNAPSHOT] ❌ Erreur:', error.message);
}
}
// Configuration Microsoft O365
const msalConfig = {
auth: {
clientId: process.env.AZURE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
clientSecret: process.env.AZURE_CLIENT_SECRET
}
};
const cca = new ConfidentialClientApplication(msalConfig);
async function getGraphToken() {
try {
console.log('🔑 Demande token Graph API...');
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.AZURE_CLIENT_ID,
client_secret: process.env.AZURE_CLIENT_SECRET,
scope: 'https://graph.microsoft.com/.default'
});
const response = await fetch(
`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
}
);
console.log('📊 Réponse OAuth - Status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Erreur OAuth:', errorText);
throw new Error(`OAuth Error: ${response.status}`);
}
const data = await response.json();
if (data.access_token) {
console.log('✅ Token obtenu');
return data.access_token;
} else {
console.error('❌ Pas de token dans la réponse:', data);
return null;
}
} catch (error) {
console.error('❌ Erreur getGraphToken:', error.message);
return null;
}
}
function agregerLignesParEmploye(demandes) {
const employesMap = new Map();
demandes.forEach(row => {
const key = row.collaborateurId;
if (!employesMap.has(key)) {
employesMap.set(key, {
employe: row.employe,
email: row.email,
service: row.service || 'Non assigné',
rtt: { nb: 0, dates: [], periodes: [] },
cp: { nb: 0, dates: [], periodes: [] },
aap: { nb: 0, dates: [], periodes: [] },
am: { nb: 0, dates: [], periodes: [] },
jposf: { nb: 0, dates: [], periodes: [] },
recup: { nb: 0, dates: [], periodes: [] },
autres: []
});
}
const employe = employesMap.get(key);
const dateDebutType = row.dateDebutType ? new Date(row.dateDebutType) : new Date(row.DateDebut);
const dateFinType = row.dateFinType ? new Date(row.dateFinType) : new Date(row.DateFin);
// Pour les demi-journées sans date propre au type,
// la date de fin = date de début (une seule journée)
const periodeJourneeRaw = row.PeriodeJournee || '';
const isDemiJournee = periodeJourneeRaw === 'Matin' || periodeJourneeRaw === 'Après-midi';
const dateFinEffective = (isDemiJournee && !row.dateFinType) ? dateDebutType : dateFinType;
const formatDate = (date) =>
String(date.getDate()).padStart(2, '0') + '/' +
String(date.getMonth() + 1).padStart(2, '0');
const dateDebutStr = formatDate(dateDebutType);
const dateFinStr = formatDate(dateFinEffective);
const periode = dateDebutStr === dateFinStr
? dateDebutStr
: `${dateDebutStr}-${dateFinStr}`;
const nombreJours = parseFloat(row.NombreJours);
const typeConge = row.typeConge.toLowerCase();
let periodeJournee = row.PeriodeJournee || 'Journée';
if (periodeJournee === 'Journée entière') periodeJournee = 'Journée';
if (typeConge === 'jposf') {
employe.jposf.nb += nombreJours;
employe.jposf.dates.push(periode);
employe.jposf.periodes.push(periodeJournee);
} else if (typeConge.includes('récup') || typeConge.includes('recup')) {
employe.aap.nb += nombreJours;
employe.aap.dates.push(periode);
employe.aap.periodes.push(periodeJournee);
} else if (typeConge.includes('rtt')) {
employe.rtt.nb += nombreJours;
employe.rtt.dates.push(periode);
employe.rtt.periodes.push(periodeJournee);
} else if (typeConge.includes('cp') || typeConge.includes('congé payé') || typeConge.includes('conge paye')) {
employe.cp.nb += nombreJours;
employe.cp.dates.push(periode);
employe.cp.periodes.push(periodeJournee);
} else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') || typeConge.includes('enfant malade')) {
employe.aap.nb += nombreJours;
employe.aap.dates.push(periode);
employe.aap.periodes.push(periodeJournee);
} else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) {
employe.am.nb += nombreJours;
employe.am.dates.push(periode);
employe.am.periodes.push(periodeJournee);
} else {
const existingAutre = employe.autres.find(a => a.type === row.typeConge);
if (existingAutre) {
existingAutre.nb += nombreJours;
existingAutre.dates.push(periode);
existingAutre.periodes.push(periodeJournee);
} else {
employe.autres.push({
type: row.typeConge,
nb: nombreJours,
dates: [periode],
periodes: [periodeJournee]
});
}
}
});
return Array.from(employesMap.values()).map(emp => ({
employe: emp.employe,
email: emp.email,
service: emp.service,
rtt: { nb: emp.rtt.nb, dates: emp.rtt.dates.join(' ; '), periodes: emp.rtt.periodes.join(' ; ') },
cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; '), periodes: emp.cp.periodes.join(' ; ') },
aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; '), periodes: emp.aap.periodes.join(' ; ') },
am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; '), periodes: emp.am.periodes.join(' ; ') },
jposf: { nb: emp.jposf.nb, dates: emp.jposf.dates.join(' ; '), periodes: emp.jposf.periodes.join(' ; ') },
recup: { nb: emp.recup.nb, dates: emp.recup.dates.join(' ; '), periodes: emp.recup.periodes.join(' ; ') },
autres: emp.autres.map(a => ({
type: a.type,
nb: a.nb,
dates: a.dates.join(' ; '),
periodes: a.periodes.join(' ; ')
}))
}));
}
function grouperParMoisConge(lignes) {
// 1. Séparer les lignes par mois de congé
const parMois = {};
lignes.forEach(row => {
const dateConge = new Date(row.dateDebutType || row.DateDebut);
const moisKey = `${dateConge.getFullYear()}-${String(dateConge.getMonth() + 1).padStart(2, '0')}`;
if (!parMois[moisKey]) {
parMois[moisKey] = [];
}
parMois[moisKey].push(row);
});
// 2. Dans chaque mois, agréger par employé
const result = {};
Object.keys(parMois).sort().forEach(mois => {
result[mois] = agregerLignesParEmploye(parMois[mois]);
});
return result;
}
function estTeletravail(typeCongeNom) {
if (!typeCongeNom) return false;
const nom = typeCongeNom.toLowerCase();
return nom.includes('télétravail') || nom.includes('teletravail') || nom.includes('remote');
}
/**
* Détermine si un type de congé est un arrêt maladie
*/
function estArretMaladie(typeCongeNom) {
if (!typeCongeNom) return false;
const nom = typeCongeNom.toLowerCase();
return nom.includes('maladie') || nom.includes('arrêt') || nom.includes('arret') || nom.includes('am');
}
/**
* Génère le sujet et le corps HTML de l'email selon le contexte
* @param {string} collaborateurNom - Prénom Nom du collaborateur
* @param {string} datesPeriode - Ex: "du 02/03 au 05/03" ou "02/03 (Matin)"
* @param {number} totalJours - Nombre de jours
* @param {string[]} typesCongeNoms - Tableau des noms de types de congé
* @param {string|null} commentaire - Commentaire optionnel
* @param {string} periodeJournee - "Journée entière", "Matin", "Après-midi"
* @returns {{ subject: string, body: string }}
*/
function genererEmailSaisieManuelle(
collaborateurNom,
datesPeriode,
totalJours,
typesCongeNoms,
commentaire,
periodeJournee
) {
const tousLesTeletravail = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estTeletravail(t));
const tousArretMaladie = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estArretMaladie(t));
const contientTeletravail = typesCongeNoms.some(t => estTeletravail(t));
const contientMaladie = typesCongeNoms.some(t => estArretMaladie(t));
// ─── CAS 1 : Télétravail pur ─────────────────────────────────────────────
if (tousLesTeletravail) {
return {
subject: '🏠 Télétravail enregistré',
body: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #3b82f6; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">🏠 Télétravail enregistré</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p>Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Votre journée de télétravail a été enregistrée par le service RH.</p>
<div style="background-color: #eff6ff; border-left: 4px solid #3b82f6; padding: 15px; margin: 20px 0; border-radius: 0 4px 4px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0; color: #6b7280; width: 40%;">📅 Période :</td>
<td style="padding: 4px 0; font-weight: bold;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">⏱️ Durée :</td>
<td style="padding: 4px 0; font-weight: bold;">${totalJours} jour${totalJours > 1 ? 's' : ''}</td>
</tr>
${periodeJournee && periodeJournee !== 'Journée entière' ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">🕐 Horaire :</td>
<td style="padding: 4px 0; font-weight: bold;">${periodeJournee}</td>
</tr>` : ''}
${commentaire ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">💬 Commentaire :</td>
<td style="padding: 4px 0;">${commentaire}</td>
</tr>` : ''}
</table>
</div>
<p style="color: #6b7280; font-size: 13px; border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 20px;">
Pour toute question, contactez le service RH.
</p>
</div>
</div>`
};
}
// ─── CAS 2 : Arrêt maladie pur ──────────────────────────────────────────
if (tousArretMaladie) {
return {
subject: '🩺 Arrêt maladie enregistré',
body: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">🩺 Arrêt maladie enregistré</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p>Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Votre arrêt maladie a bien été pris en compte dans notre système.</p>
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; border-radius: 0 4px 4px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0; color: #6b7280; width: 40%;">📅 Période :</td>
<td style="padding: 4px 0; font-weight: bold;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">⏱️ Durée :</td>
<td style="padding: 4px 0; font-weight: bold;">${totalJours} jour${totalJours > 1 ? 's' : ''}</td>
</tr>
${commentaire ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">💬 Commentaire :</td>
<td style="padding: 4px 0;">${commentaire}</td>
</tr>` : ''}
</table>
</div>
<p style="color: #6b7280; font-size: 13px; border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 20px;">
Pour toute question, contactez le service RH.
</p>
</div>
</div>`
};
}
// ─── CAS 3 : Mix contenant du télétravail (ex: télétravail + CP) ────────
if (contientTeletravail) {
const autresTypes = typesCongeNoms.filter(t => !estTeletravail(t));
return {
subject: '📋 Absence mixte enregistrée',
body: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #8b5cf6; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">📋 Absence mixte enregistrée</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p>Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Votre absence a été enregistrée et <strong style="color: #10b981;">validée automatiquement</strong> par le service RH.</p>
<div style="background-color: white; border-left: 4px solid #8b5cf6; padding: 15px; margin: 20px 0; border-radius: 0 4px 4px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0; color: #6b7280; width: 40%;">📅 Période :</td>
<td style="padding: 4px 0; font-weight: bold;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">📁 Types :</td>
<td style="padding: 4px 0;">${typesCongeNoms.join(', ')}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">⏱️ Durée :</td>
<td style="padding: 4px 0; font-weight: bold;">${totalJours} jour${totalJours > 1 ? 's' : ''}</td>
</tr>
${commentaire ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">💬 Commentaire :</td>
<td style="padding: 4px 0;">${commentaire}</td>
</tr>` : ''}
</table>
</div>
<p style="color: #6b7280; font-size: 13px; border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 20px;">
Pour toute question, contactez le service RH.
</p>
</div>
</div>`
};
}
// ─── CAS 4 : Congé classique (CP, RTT, AAP, Récupération, etc.) ─────────
return {
subject: '✅ Congé enregistré et validé',
body: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #10b981; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">✅ Congé validé automatiquement</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p>Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Une demande de congé a été enregistrée et <strong style="color: #10b981;">validée automatiquement</strong> par le service RH.</p>
<div style="background-color: white; border-left: 4px solid #10b981; padding: 15px; margin: 20px 0; border-radius: 0 4px 4px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0; color: #6b7280; width: 40%;">📅 Période :</td>
<td style="padding: 4px 0; font-weight: bold;">${datesPeriode}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">📁 Type :</td>
<td style="padding: 4px 0;">${typesCongeNoms.join(', ')}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #6b7280;">⏱️ Durée :</td>
<td style="padding: 4px 0; font-weight: bold;">${totalJours} jour${totalJours > 1 ? 's' : ''}</td>
</tr>
${periodeJournee && periodeJournee !== 'Journée entière' ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">🕐 Horaire :</td>
<td style="padding: 4px 0; font-weight: bold;">${periodeJournee}</td>
</tr>` : ''}
${commentaire ? `
<tr>
<td style="padding: 4px 0; color: #6b7280;">💬 Commentaire :</td>
<td style="padding: 4px 0;">${commentaire}</td>
</tr>` : ''}
</table>
</div>
<p style="color: #6b7280; font-size: 13px; border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 20px;">
Pour toute question, contactez le service RH.
</p>
</div>
</div>`
};
}
async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) {
try {
console.log('📤 Envoi email via Graph API...');
console.log(' From:', fromEmail);
console.log(' To:', toEmail);
console.log(' Subject:', subject);
const response = await fetch(
`https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: {
subject,
body: { contentType: 'HTML', content: bodyHtml },
toRecipients: [{ emailAddress: { address: toEmail } }]
},
saveToSentItems: false
})
}
);
console.log('📊 Réponse Graph API - Status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Erreur Graph API:', errorText);
throw new Error(`Graph API Error: ${response.status} - ${errorText}`);
}
console.log('✅ Email envoyé avec succès');
return true;
} catch (error) {
console.error('❌ Erreur sendMailGraph:', error.message);
return false;
}
}
// Middleware d'authentification
const authenticateToken = (req, res, next) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token requis' });
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Token invalide' });
req.user = user;
next();
});
};
// ================================================
// GESTION DES SERVER-SENT EVENTS (SSE)
// ================================================
const sseClients = new Set();
// 🔌 ROUTE SSE POUR LE CALENDRIER
app.get('/events', (req, res) => {
const { token, userid } = req.query;
let userId = userid;
// ✅ Si token fourni, extraire l'ID utilisateur
if (token && !userId) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
userId = decoded.id;
console.log('🔓 Token JWT décodé, userId:', userId);
} catch (error) {
console.error('❌ Token invalide:', error.message);
return res.status(401).json({ error: 'Token invalide' });
}
}
if (!userId) {
console.error('❌ Ni userid ni token fourni');
return res.status(400).json({ error: 'userid ou token requis' });
}
console.log('🔗 Nouvelle connexion SSE (RH):', userId);
// Headers SSE critiques
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// Flush pour établir la connexion
res.flushHeaders();
const sendEvent = (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch (error) {
console.error('Erreur envoi SSE:', error);
}
};
const client = { id: userId, send: sendEvent };
sseClients.add(client);
console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
// Envoyer un heartbeat initial
sendEvent({ type: 'ping', message: 'Connexion établie', timestamp: new Date().toISOString() });
// Heartbeat toutes les 30 secondes
const heartbeat = setInterval(() => {
try {
sendEvent({ type: 'ping', timestamp: new Date().toISOString() });
} catch (error) {
clearInterval(heartbeat);
}
}, 30000);
// Gérer la déconnexion
req.on('close', () => {
console.log('🔌 Déconnexion SSE (RH):', userId);
clearInterval(heartbeat);
sseClients.delete(client);
console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
});
});
// 📢 FONCTION POUR NOTIFIER LES CLIENTS
const notifyClients = (event, userId = null) => {
console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`);
sseClients.forEach(client => {
if (userId && client.id !== userId) {
return;
}
try {
client.send(event);
} catch (error) {
console.error('❌ Erreur envoi event:', error);
}
});
};
// ================================================
// ROUTE WEBHOOK RECEIVER
// ================================================
app.post('/webhook/receive', async (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const payload = req.body;
console.log('📥 Webhook reçu du serveur RH:', payload.event);
if (!webhookManager.verifySignature(payload, signature)) {
console.error('❌ Signature webhook invalide');
return res.status(401).json({ error: 'Signature invalide' });
}
const { event, data } = payload;
console.log('✅ Signature valide ! Traitement...');
console.log(' Event:', event);
console.log(' Data:', data);
switch (event) {
case EVENTS.DEMANDE_VALIDATED:
console.log(`\n✅ === WEBHOOK DEMANDE_VALIDATED REÇU ===`);
console.log(` Demande: ${data.demandeId}`);
console.log(` Statut: ${data.statut}`);
console.log(` Type: ${data.type || data.typeConge}`);
console.log(` Couleur: ${data.couleurHex}`);
try {
// Notification SSE locale (serveur RH)
notifyClients({
type: 'demande-validated',
demandeId: demandeId,
statut: 'Validé',
typeConge: 'JPOSF',
couleurHex: '#d946ef',
collaborateurId: parseInt(collaborateurid),
timestamp: new Date().toISOString()
}, parseInt(collaborateurid));
// 🔧 WEBHOOK vers serveur COLLABORATEURS (pour calendrier)
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDEVALIDATED,
{
demandeId: demandeId,
statut: 'Validé',
typeConge: 'JPOSF',
couleurHex: '#d946ef',
date: date, // ← ESSENTIEL
dateDebut: date, // ← ESSENTIEL
dateFin: date, // ← ESSENTIEL
periode: periodejournee, // ← ESSENTIEL
periodeJournee: periodejournee,
nombreJours: nombreJours,
collaborateurId: parseInt(collaborateurid)
}
);
console.log('✅ Webhook JPOSF envoyé au serveur collaborateurs');
} catch (sseError) {
console.error('❌ Erreur SSE/Webhook:', sseError.message);
}
case EVENTS.DEMANDE_CANCELLED:
console.log(`\n🔴 === WEBHOOK DEMANDE_CANCELLED REÇU ===`);
console.log(` Demande: ${data.demandeId}`);
console.log(` Annulée par: ${data.annulateurRole}`);
console.log(` Commentaire: ${data.commentaire || 'Aucun'}`);
notifyClients({
type: 'demande-cancelled',
demandeId: data.demandeId,
statut: 'Annulée',
collaborateurId: data.collaborateurId,
annulateurRole: data.annulateurRole,
commentaire: data.commentaire,
timestamp: new Date().toISOString()
});
notifyClients({
type: 'demande-updated',
demandeId: data.demandeId,
nouveauStatut: 'Annulée',
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(` ✅ SSE envoyé - Demande ${data.demandeId} marquée comme annulée`);
break;
case EVENTS.COMPTEUR_UPDATED:
console.log(`\n🔄 === WEBHOOK COMPTEUR_UPDATED REÇU ===`);
console.log(` Collaborateur: ${data.collaborateurId}`);
notifyClients({
type: 'compteur-updated',
collaborateurId: data.collaborateurId,
typeConge: data.typeConge,
typeUpdate: data.typeUpdate,
jours: data.jours,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(` ✅ SSE envoyé`);
break;
case EVENTS.DEMANDE_UPDATED:
console.log(`✏️ Demande ${data.demandeId} modifiée via RH`);
notifyClients({
type: 'demande-updated-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
break;
case EVENTS.DEMANDE_DELETED:
console.log(`🗑️ Demande ${data.demandeId} supprimée via RH`);
notifyClients({
type: 'demande-deleted-rh',
demandeId: data.demandeId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
break;
default:
console.warn(`⚠️ Type d'événement webhook inconnu: ${event}`);
}
res.json({ success: true, message: 'Webhook traité' });
} catch (error) {
console.error('❌ Erreur traitement webhook:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTES D'AUTHENTIFICATION
// ================================================
app.get('/users-dev', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT id, email, nom, prenom, role, service, Actif
FROM CollaborateurAD
WHERE Actif = 1 OR Actif IS NULL
ORDER BY nom, prenom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur users-dev:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/login-dev', async (req, res) => {
try {
console.log('🔐 Login attempt started');
const { accessToken } = req.body;
if (!accessToken) {
console.error('❌ No access token provided');
return res.status(400).json({ error: 'Token d\'accès manquant' });
}
console.log('👤 Validating access token...');
let userInfo;
try {
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!graphResponse.ok) {
throw new Error('Token invalide');
}
userInfo = await graphResponse.json();
console.log('✅ Token validated, user:', userInfo.mail || userInfo.userPrincipalName);
} catch (graphError) {
console.error('❌ Graph API Error:', graphError);
return res.status(401).json({
error: 'Token d\'accès invalide',
details: graphError.message
});
}
const userEmail = userInfo.mail || userInfo.userPrincipalName;
// ⭐ REQUÊTE SQL SERVER
const result = await pool.request()
.input('email', sql.VarChar, userEmail)
.query(`
SELECT * FROM CollaborateurAD
WHERE email = @email
AND (Actif = 1 OR Actif IS NULL)
`);
const users = result.recordset;
if (users.length === 0) {
console.warn('⚠️ User not found or inactive:', userEmail);
return res.status(404).json({
error: 'Utilisateur non trouvé ou compte désactivé',
details: `Aucun utilisateur actif avec l'email ${userEmail}`
});
}
const user = users[0];
console.log('👤 User found:', user.email, 'Role:', user.role);
if (!['RH', 'Admin', 'Validateur'].includes(user.role)) {
console.warn('⚠️ Unauthorized role:', user.role);
return res.status(403).json({
error: 'Accès non autorisé',
details: `Le rôle '${user.role}' n'a pas accès à cette application`
});
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
console.log('✅ Login successful for:', user.email);
res.json({
token,
user: {
id: user.id,
nom: user.nom,
prenom: user.prenom,
role: user.role
}
});
} catch (error) {
console.error('❌ Unexpected error in /login-dev:', error);
res.status(500).json({
error: 'Erreur serveur inattendue',
details: error.message
});
}
});
// ================================================
// ROUTES DES DEMANDES DE CONGÉS
// ================================================
// ================================================
// ROUTE POST /demandes - VERSION MODIFIÉE AVEC PRIORITÉ CP N-1 puis CP N
// Remplacer la route existante par celle-ci
// ================================================
app.post('/demandes', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const { collaborateurId, dateDebut, dateFin, typesConge, commentaire, saisieManuelle, periodeJournee } = req.body;
const currentYear = new Date().getFullYear();
console.log('📝 Création demande:', { collaborateurId, dateDebut, dateFin, saisieManuelle, periodeJournee });
// Vérifier que le collaborateur est actif
const collabResult = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.query('SELECT Actif, prenom, nom, email FROM CollaborateurAD WHERE id = @collabId');
const collab = collabResult.recordset;
if (collab.length === 0 || collab[0].Actif === 0) {
await transaction.rollback();
return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' });
}
// ── Vérification anti-doublon avant création ──────────────────
const doublonCheck = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.query(`
SELECT Id FROM DemandeConge
WHERE CollaborateurADId = @collabId
AND Statut IN ('Validée', 'Validé', 'En attente')
AND DateDebut <= @dateFin AND DateFin >= @dateDebut
`);
if (doublonCheck.recordset.length > 0) {
await transaction.rollback();
return res.status(409).json({
error: `Une demande existe déjà pour ${collab[0].prenom} ${collab[0].nom} sur cette période.`
});
}
const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
const statut = saisieManuelle ? 'Validée' : 'En attente';
console.log(`📝 Création demande - Saisie manuelle: ${saisieManuelle}, Statut: ${statut}`);
// GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGE
const maxIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge');
const demandeId = maxIdResult.recordset[0].NextId;
console.log(`📝 Nouvel ID DemandeConge généré: ${demandeId}`);
if (saisieManuelle) {
await transaction.request()
.input('id', sql.Int, demandeId)
.input('collaborateurId', sql.Int, collaborateurId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.input('commentaire', sql.NVarChar, commentaire || 'Saisie manuelle RH')
.input('totalJours', sql.Decimal(10, 2), totalJours)
.input('validateurId', sql.Int, req.user.id)
.query(`
INSERT INTO DemandeConge
(Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
`);
} else {
await transaction.request()
.input('id', sql.Int, demandeId)
.input('collaborateurId', sql.Int, collaborateurId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.input('commentaire', sql.NVarChar, commentaire || '')
.input('totalJours', sql.Decimal(10, 2), totalJours)
.query(`
INSERT INTO DemandeConge
(Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours)
VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'En attente', @commentaire, GETDATE(), @totalJours)
`);
}
console.log(`✅ DemandeConge créée - ID: ${demandeId}`);
// GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGETUPE avec PeriodeJournee
for (const type of typesConge) {
const maxTypeIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
const typeId = maxTypeIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, typeId)
.input('demandeId', sql.Int, demandeId)
.input('typeCongeId', sql.Int, type.typeId)
.input('nombreJours', sql.Decimal(10, 2), type.nombreJours)
.input('periode', sql.NVarChar(50), periodeJournee || 'Journée entière')
.query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeCongeId, @nombreJours, @periode)');
console.log(` ✅ DemandeCongeType créée - ID: ${typeId}, TypeCongeId: ${type.typeId}, Période: ${periodeJournee || 'Journée entière'}`);
}
// ⭐ Si saisie manuelle : déduction avec DeductionDetails (même logique que serverGTA)
if (saisieManuelle) {
console.log('💼 Saisie manuelle - Déduction via DeductionDetails...');
// ⭐ NOUVEAU : Pré-charger les noms des types pour détecter le télétravail
const typeIds = typesConge.map(t => t.typeId);
const typesInfoResult = await transaction.request()
.query(`SELECT Id, Nom FROM TypeConge WHERE Id IN (${typeIds.join(',')})`);
const typeNomMap = {};
typesInfoResult.recordset.forEach(t => { typeNomMap[t.Id] = t.Nom; });
// Déterminer l'exercice CP courant (Annee = année de FIN d'exercice)
// Ex : avant 01/06/currentYear → anneeN=currentYear ; après → anneeN=currentYear+1
const dateBasculeCP = new Date(currentYear, 5, 1); // 01/06
const apresBasculement = new Date() >= dateBasculeCP;
const anneeN = apresBasculement ? currentYear + 1 : currentYear;
const anneeN1 = anneeN - 1;
// Exercice du congé (pour détecter l'anticipé)
const dateDebutObj = new Date(dateDebut);
const moisDebut = dateDebutObj.getMonth() + 1;
const anneeDebut = dateDebutObj.getFullYear();
const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
const isAnticipe = anneeExConge > anneeN;
// Exercice RTT = année civile du congé
const anneeRTT = anneeDebut;
const isAnticipeRTT = anneeRTT > currentYear;
for (const type of typesConge) {
const typeId = type.typeId;
const nombreJours = parseFloat(type.nombreJours);
const typeNom = typeNomMap[typeId] || '';
// ⭐ NOUVEAU : Télétravail = jour travaillé, pas une absence → aucune déduction
if (estTeletravail(typeNom)) {
console.log(`🏠 Télétravail (${typeNom}) - ${nombreJours}j - Aucune déduction de compteur`);
continue;
}
// ── CP (typeId === 1) ──────────────────────────────────────────
if (typeId === 1) {
console.log(`\n🔄 CP - ${nombreJours}j à déduire | isAnticipe=${isAnticipe} | anneeExConge=${anneeExConge} | anneeN=${anneeN}`);
let joursRestants = nombreJours;
if (!isAnticipe) {
// ── MODE NORMAL : N-1 puis N ──────────────────────────
// 1️⃣ CP N-1
const cptN1Res = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN1)
.query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
if (cptN1Res.recordset.length > 0) {
const cptN1 = cptN1Res.recordset[0];
const soldeN1 = parseFloat(cptN1.Solde || 0);
const aDeduireN1 = Math.min(soldeN1, joursRestants);
if (aDeduireN1 > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), aDeduireN1)
.input('id', sql.Int, cptN1.Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, SoldeReporte=CASE WHEN SoldeReporte-@ded<0 THEN 0 ELSE SoldeReporte-@ded END, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN1)
.input('jours', sql.Decimal(10, 2), aDeduireN1)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N-1', @jours)`);
console.log(` ✅ CP Année N-1 (${anneeN1}) : -${aDeduireN1}j`);
joursRestants -= aDeduireN1;
}
}
// 2️⃣ CP N
if (joursRestants > 0) {
const cptNRes = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
if (cptNRes.recordset.length > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), joursRestants)
.input('id', sql.Int, cptNRes.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.input('jours', sql.Decimal(10, 2), joursRestants)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
console.log(` ✅ CP Année N (${anneeN}) : -${joursRestants}j`);
joursRestants = 0;
} else {
console.warn(` ⚠️ Compteur CP N (${anneeN}) introuvable`);
}
}
} else {
// ── MODE ANTICIPÉ : tout tracé sur anneeN ─────────────
// Projeter solde CP N jusqu'au 31/05/anneeN
const TAUX_CP = 25 / 360;
const cptNRes = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
const soldeNActuel = cptNRes.recordset.length > 0 ? Math.max(0, parseFloat(cptNRes.recordset[0].Solde || 0)) : 0;
const finExN = new Date(anneeN, 4, 31);
const joursRestantsExN = Math.max(0, Math.floor((finExN - new Date()) / 86400000));
const acquisRestantsN = parseFloat(Math.min(25 - soldeNActuel, joursRestantsExN * TAUX_CP).toFixed(2));
const soldeNProj = parseFloat(Math.min(25, soldeNActuel + acquisRestantsN).toFixed(2));
// Part sur solde réel actuel → TypeDeduction='Anticip'
const aDeduireAnticip = Math.min(soldeNActuel, joursRestants);
if (aDeduireAnticip > 0 && cptNRes.recordset.length > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), aDeduireAnticip)
.input('id', sql.Int, cptNRes.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.input('jours', sql.Decimal(10, 2), aDeduireAnticip)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Anticip', @jours)`);
console.log(` ✅ CP Anticip (${anneeN}) : -${aDeduireAnticip}j sur solde réel`);
joursRestants -= aDeduireAnticip;
}
// Part sur acquisitions futures → TypeDeduction='AnticipFutur'
if (joursRestants > 0) {
const aDeduireAnticipFutur = Math.min(acquisRestantsN, joursRestants);
if (aDeduireAnticipFutur > 0) {
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.input('jours', sql.Decimal(10, 2), aDeduireAnticipFutur)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
console.log(` ✅ CP AnticipFutur (${anneeN}) : ${aDeduireAnticipFutur}j tracé (acquisitions futures)`);
joursRestants -= aDeduireAnticipFutur;
}
}
// Part sur CP N+1 (après 01/06/anneeN) → TypeDeduction='AnticipFutur' sur anneeExConge
if (joursRestants > 0) {
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeExConge)
.input('jours', sql.Decimal(10, 2), joursRestants)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
console.log(` ✅ CP AnticipFutur N+1 (${anneeExConge}) : ${joursRestants}j tracé`);
joursRestants = 0;
}
}
if (joursRestants > 0) {
console.warn(` ⚠️ CP : ${joursRestants}j non couverts (solde insuffisant, traçage forcé sur anneeN)`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeN)
.input('jours', sql.Decimal(10, 2), joursRestants)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
}
// ── RTT (typeId === 2) ─────────────────────────────────────────
} else if (typeId === 2) {
console.log(`\n🔄 RTT - ${nombreJours}j à déduire | isAnticipeRTT=${isAnticipeRTT} | anneeRTT=${anneeRTT}`);
let joursRestants = nombreJours;
const cptRTTRes = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeRTT)
.query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
if (!isAnticipeRTT) {
// ── MODE NORMAL RTT ──────────────────────────────────────
if (cptRTTRes.recordset.length > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), joursRestants)
.input('id', sql.Int, cptRTTRes.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeRTT)
.input('jours', sql.Decimal(10, 2), joursRestants)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
console.log(` ✅ RTT Année N (${anneeRTT}) : -${joursRestants}j`);
joursRestants = 0;
} else {
console.warn(` ⚠️ Compteur RTT (${anneeRTT}) introuvable`);
}
} else {
// ── MODE ANTICIPÉ RTT ────────────────────────────────────
const soldeRTTActuel = cptRTTRes.recordset.length > 0 ? Math.max(0, parseFloat(cptRTTRes.recordset[0].Solde || 0)) : 0;
// Part sur solde réel → AnticipRTT
const aDeduireAnticipRTT = Math.min(soldeRTTActuel, joursRestants);
if (aDeduireAnticipRTT > 0 && cptRTTRes.recordset.length > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), aDeduireAnticipRTT)
.input('id', sql.Int, cptRTTRes.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeRTT)
.input('jours', sql.Decimal(10, 2), aDeduireAnticipRTT)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipRTT', @jours)`);
console.log(` ✅ RTT AnticipRTT (${anneeRTT}) : -${aDeduireAnticipRTT}j`);
joursRestants -= aDeduireAnticipRTT;
}
// Part sur acquisitions futures → AnticipFuturRTT
if (joursRestants > 0) {
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeRTT)
.input('jours', sql.Decimal(10, 2), joursRestants)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFuturRTT', @jours)`);
console.log(` ✅ RTT AnticipFuturRTT (${anneeRTT}) : ${joursRestants}j tracé`);
joursRestants = 0;
}
}
// ── Autres types (Récupération, JPOSF, etc.) ──────────────────
} else {
const anneeType = anneeDebut; // année civile du congé
const cptRes = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeType)
.query(`SELECT TOP 1 Id FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
if (cptRes.recordset.length > 0) {
await transaction.request()
.input('ded', sql.Decimal(10, 2), nombreJours)
.input('id', sql.Int, cptRes.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
} else {
// Fallback : update sans filtre d'année (comportement original)
await transaction.request()
.input('ded', sql.Decimal(10, 2), nombreJours)
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeId)
.query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=YEAR(GETDATE())`);
}
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, typeId)
.input('annee', sql.Int, anneeType)
.input('jours', sql.Decimal(10, 2), nombreJours)
.query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
console.log(` ✅ Type ${typeId} : -${nombreJours}j (Année N ${anneeType})`);
}
}
// GÉNÉRATION MANUELLE DE L'ID POUR HISTORIQUEACTIONS
try {
const maxHistIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
const histId = maxHistIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, histId)
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`)
.input('demandeId', sql.Int, demandeId)
.query(`
INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@id, @userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE())
`);
} catch (histError) {
console.log('⚠️ Tentative sans ID pour HistoriqueActions...');
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`)
.input('demandeId', sql.Int, demandeId)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE())
`);
}
}
await transaction.commit();
// Notifications SSE
if (saisieManuelle) {
notifyClients({
type: 'demande-validated',
demandeId: demandeId,
statut: 'Validée',
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
}, collaborateurId);
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
action: 'saisie_manuelle_rh',
timestamp: new Date().toISOString()
}, collaborateurId);
// Webhooks
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_VALIDATED,
{ demandeId, statut: 'Validée', collaborateurId, validateurId: req.user.id }
);
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId }
);
} catch (webhookError) {
console.error('⚠️ Erreur webhook:', webhookError.message);
}
// Email
try {
const accessToken = await getGraphToken();
if (accessToken && collab[0].email) {
const collaborateurNom = `${collab[0].prenom} ${collab[0].nom}`;
const dateDebutFormatted = new Date(dateDebut).toLocaleDateString('fr-FR');
const dateFinFormatted = new Date(dateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebutFormatted === dateFinFormatted
? `${dateDebutFormatted}${periodeJournee && periodeJournee !== 'Journée entière' ? ` (${periodeJournee})` : ''}`
: `du ${dateDebutFormatted} au ${dateFinFormatted}`;
// Récupérer les noms des types de congé
const typesCongeNoms = [];
for (const type of typesConge) {
const nomRes = await pool.request()
.input('typeId', sql.Int, type.typeId)
.query('SELECT Nom FROM TypeConge WHERE Id = @typeId');
if (nomRes.recordset.length > 0) typesCongeNoms.push(nomRes.recordset[0].Nom);
}
const { subject, body } = genererEmailSaisieManuelle(
collaborateurNom,
datesPeriode,
totalJours,
typesCongeNoms,
commentaire,
periodeJournee
);
await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab[0].email, subject, body);
}
} catch (emailError) {
console.error('⚠️ Erreur email:', emailError.message);
}
} else {
notifyClients({
type: 'demande-created',
demandeId: demandeId,
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
});
}
console.log(`✅ Demande ${demandeId} créée avec statut: ${statut}`);
res.json({
id: demandeId,
message: saisieManuelle ? 'Demande créée et validée automatiquement' : 'Demande créée avec succès',
statut: statut
});
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur création demande:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTES COMPTE-RENDU D'ACTIVITÉS
// ================================================
// GET /api/compte-rendu/services - Liste des services avec forfaits jour
app.get('/compte-rendu/services', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const result = await pool.request().query(`
SELECT
s.Id,
s.Nom,
COUNT(DISTINCT ca.id) as nombreForfaitJour
FROM Services s
LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId
AND ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
GROUP BY s.Id, s.Nom
HAVING COUNT(DISTINCT ca.id) > 0
ORDER BY s.Nom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /compte-rendu/services:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/compte-rendu/stats - Stats globales des comptes-rendus
app.get('/compte-rendu/stats', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { annee, mois, service_id } = req.query;
const request = pool.request()
.input('annee', sql.Int, parseInt(annee))
.input('mois', sql.Int, parseInt(mois));
let serviceFilter = '';
if (service_id && service_id !== 'all') {
serviceFilter = 'AND ca.ServiceId = @serviceId';
request.input('serviceId', sql.Int, parseInt(service_id));
}
// Compter les forfaits jour actifs
const totalResult = await request.query(`
SELECT COUNT(DISTINCT ca.id) as total
FROM CollaborateurAD ca
WHERE ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
${serviceFilter}
`);
const totalCollaborateurs = totalResult.recordset[0].total;
// Compter les mensuels validés (verrouillés)
const validesRequest = pool.request()
.input('annee', sql.Int, parseInt(annee))
.input('mois', sql.Int, parseInt(mois));
if (service_id && service_id !== 'all') {
validesRequest.input('serviceId', sql.Int, parseInt(service_id));
}
const validesResult = await validesRequest.query(`
SELECT COUNT(DISTINCT m.CollaborateurADId) as valides
FROM CompteRenduMensuel m
JOIN CollaborateurAD ca ON m.CollaborateurADId = ca.id
WHERE m.Annee = @annee
AND m.Mois = @mois
AND m.Verrouille = 1
AND ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
${serviceFilter}
`);
const mensuelsValides = validesResult.recordset[0].valides || 0;
const mensuelsEnAttente = totalCollaborateurs - mensuelsValides;
res.json({
totalCollaborateurs,
mensuelsValides,
mensuelsEnAttente
});
} catch (error) {
console.error('❌ Erreur /compte-rendu/stats:', error);
res.status(500).json({ error: error.message });
}
});
// Route GET /api/compte-rendu-activites
app.get('/compte-rendu-activites', authenticateToken, async (req, res) => {
try {
const { userid, annee, mois } = req.query;
if (!userid || !annee || !mois) {
return res.status(400).json({
success: false,
message: 'Paramètres manquants : userid, annee et mois requis'
});
}
// Vérifier si l'utilisateur est RH/Admin
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès réservé aux RH et Administrateurs' });
}
const collaborateurId = parseInt(userid);
const anneeInt = parseInt(annee);
const moisInt = parseInt(mois);
// Récupérer les jours du mois
const joursResult = await pool.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, anneeInt)
.input('mois', sql.Int, moisInt)
.query(`
SELECT
JourDate, JourTravaille, ReposQuotidienRespect,
ReposHebdomadaireRespect, CommentaireRepos,
DateSaisie, DateModification
FROM CompteRenduActivites
WHERE CollaborateurADId = @collaborateurId
AND Annee = @annee
AND Mois = @mois
ORDER BY JourDate
`);
// Récupérer les statistiques mensuelles
const mensuelResult = await pool.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, anneeInt)
.input('mois', sql.Int, moisInt)
.query(`
SELECT
CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles,
NbJoursNonRespectsReposQuotidien,
NbJoursNonRespectsReposHebdo,
Verrouille
FROM CompteRenduMensuel
WHERE CollaborateurADId = @collaborateurId
AND Annee = @annee
AND Mois = @mois
`);
const jours = joursResult.recordset;
const mensuel = mensuelResult.recordset.length > 0 ? mensuelResult.recordset[0] : null;
res.json({
success: true,
jours: jours,
mensuel: mensuel
});
} catch (error) {
console.error('Erreur /compte-rendu-activites:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// GET /api/export-compte-rendu-pdf - Export PDF
// ================================================
// ROUTE CORRIGÉE : GET /api/export-compte-rendu-pdf
// ================================================
// ================================================
// ROUTE EXPORT PDF COMPTE-RENDU (DESIGN MODERNE)
// ================================================
app.get('/export-compte-rendu-pdf', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { user_id, annee, mois } = req.query;
if (!user_id || !annee || !mois) {
return res.status(400).json({
success: false,
message: 'Paramètres manquants (user_id, annee, mois requis)'
});
}
console.log(`📄 Génération PDF moderne: User=${user_id}, ${mois}/${annee}`);
// ✅ DÉFINIR LES HEADERS
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition',
`attachment; filename="compte-rendu-${annee}-${String(mois).padStart(2, '0')}.pdf"`);
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
// ✅ REQUÊTES SQL SERVER
const collabResult = await pool.request()
.input('userId', sql.Int, parseInt(user_id))
.query(`
SELECT
ca.prenom,
ca.nom,
ca.email,
COALESCE(s.Nom, 'Non défini') as service
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
WHERE ca.id = @userId
`);
if (collabResult.recordset.length === 0) {
return res.status(404).json({
success: false,
message: 'Collaborateur non trouvé'
});
}
const collab = collabResult.recordset[0];
// Récupérer le mensuel
const mensuelResult = await pool.request()
.input('userId', sql.Int, parseInt(user_id))
.input('annee', sql.Int, parseInt(annee))
.input('mois', sql.Int, parseInt(mois))
.query(`
SELECT
CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles,
NbJoursNonRespectsReposQuotidien,
NbJoursNonRespectsReposHebdo,
Verrouille,
DateValidation
FROM CompteRenduMensuel
WHERE CollaborateurADId = @userId
AND Annee = @annee
AND Mois = @mois
`);
const mensuel = mensuelResult.recordset.length > 0
? mensuelResult.recordset[0]
: null;
// Récupérer les jours
const joursResult = await pool.request()
.input('userId', sql.Int, parseInt(user_id))
.input('annee', sql.Int, parseInt(annee))
.input('mois', sql.Int, parseInt(mois))
.query(`
SELECT
JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos
FROM CompteRenduActivites
WHERE CollaborateurADId = @userId
AND Annee = @annee
AND Mois = @mois
ORDER BY JourDate
`);
const jours = joursResult.recordset || [];
console.log(`✅ Données: ${jours.length} jours`);
// ✅ CRÉER LE PDF
const doc = new PDFDocument({
bufferPages: true,
margin: 60,
size: 'A4'
});
doc.pipe(res);
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
// ===== TITRE PRINCIPAL (CYAN) =====
doc.fontSize(24)
.fillColor('#06b6d4') // Cyan
.text('Compte-rendu d\'activités', { align: 'center' });
doc.moveDown(0.5);
// ===== SOUS-TITRE (GRIS) =====
doc.fontSize(16)
.fillColor('#6b7280')
.text(`${monthNames[parseInt(mois) - 1]} ${annee}`, { align: 'center' });
doc.moveDown(2);
// ===== SECTION COLLABORATEUR =====
doc.fontSize(12)
.fillColor('#000000')
.text('Collaborateur', { underline: true });
doc.moveDown(0.3);
doc.fontSize(10)
.fillColor('#374151')
.text(`Nom: ${collab.prenom} ${collab.nom}`)
.text(`Email: ${collab.email}`)
.text(`Service: ${collab.service}`);
doc.moveDown(1.5);
// ===== ENCADRÉ RÉSUMÉ (FOND CYAN CLAIR) =====
doc.fontSize(12)
.fillColor('#000000')
.text('Résumé du mois', { underline: true });
doc.moveDown(0.5);
const resumeY = doc.y;
const resumeHeight = 80;
// Rectangle avec fond cyan très clair et bordure cyan
doc.rect(60, resumeY, 475, resumeHeight)
.fillAndStroke('#e0f2fe', '#06b6d4');
// Contenu de l'encadré (2 colonnes)
const col1X = 75;
const col2X = 320;
const textY = resumeY + 15;
doc.fontSize(10)
.fillColor('#374151');
// Colonne 1
doc.text(`Jours travaillés: ${mensuel?.NbJoursTravailles || 0}`, col1X, textY);
doc.text(`Non-respect repos quotidien: ${mensuel?.NbJoursNonRespectsReposQuotidien || 0}`, col1X, textY + 20);
doc.text(`Non-respect repos hebdomadaire: ${mensuel?.NbJoursNonRespectsReposHebdo || 0}`, col1X, textY + 40);
// Colonne 2
const statutText = mensuel?.Verrouille ? 'Verrouillé' : 'Ouvert';
doc.text(`Statut: ${statutText}`, col2X, textY);
if (mensuel?.DateValidation) {
const dateVal = new Date(mensuel.DateValidation);
const dateStr = dateVal.toLocaleDateString('fr-FR');
doc.text(`Date validation: ${dateStr}`, col2X, textY + 20);
}
doc.y = resumeY + resumeHeight + 20;
doc.moveDown(1);
// ===== TABLEAU DÉTAIL DES JOURS =====
doc.fontSize(12)
.fillColor('#000000')
.text('Détail des jours', { align: 'center' });
doc.moveDown(0.5);
const tableTop = doc.y;
const colWidth = {
date: 120,
travaille: 80,
repos: 120,
commentaire: 155
};
let currentX = 60;
const tableHeaderY = tableTop;
// En-têtes du tableau
doc.fontSize(9)
.fillColor('#6b7280');
doc.text('Date', currentX, tableHeaderY, { width: colWidth.date, align: 'left' });
currentX += colWidth.date;
doc.text('Travaillé', currentX, tableHeaderY, { width: colWidth.travaille, align: 'center' });
currentX += colWidth.travaille;
doc.text('Respect des repos', currentX, tableHeaderY, { width: colWidth.repos, align: 'center' });
currentX += colWidth.repos;
doc.text('Commentaire', currentX, tableHeaderY, { width: colWidth.commentaire, align: 'left' });
// Ligne de séparation sous les en-têtes
doc.moveTo(60, tableHeaderY + 15)
.lineTo(535, tableHeaderY + 15)
.stroke('#d1d5db');
let currentY = tableHeaderY + 25;
const rowHeight = 20;
// Lignes du tableau
jours.forEach((jour, index) => {
// Vérifier nouvelle page
if (currentY > 720) {
doc.addPage();
currentY = 60;
}
// Alterner le fond des lignes
if (index % 2 === 0) {
doc.rect(60, currentY - 3, 475, rowHeight)
.fill('#f9fafb');
}
currentX = 60;
doc.fontSize(8)
.fillColor('#374151');
// Date
const dateStr = new Date(jour.JourDate).toLocaleDateString('fr-FR');
doc.text(dateStr, currentX, currentY, { width: colWidth.date, align: 'left' });
currentX += colWidth.date;
// Travaillé (coche simple)
const travailleText = jour.JourTravaille ? '✓' : '-';
doc.text(travailleText, currentX, currentY, { width: colWidth.travaille, align: 'center' });
currentX += colWidth.travaille;
// Respect des repos (coche verte ou rien)
const respectOk = jour.ReposQuotidienRespect && jour.ReposHebdomadaireRespect;
if (respectOk) {
// Dessiner une coche verte circulaire
const centerX = currentX + colWidth.repos / 2;
const centerY = currentY + 5;
doc.circle(centerX, centerY, 6)
.fillAndStroke('#22c55e', '#16a34a');
doc.fontSize(8)
.fillColor('#ffffff')
.text('✓', centerX - 3, centerY - 4, { width: 6, align: 'center' });
} else {
doc.fillColor('#374151')
.text('-', currentX, currentY, { width: colWidth.repos, align: 'center' });
}
currentX += colWidth.repos;
// Commentaire
const comment = jour.CommentaireRepos || '-';
doc.fillColor('#374151')
.text(comment.substring(0, 25), currentX, currentY, { width: colWidth.commentaire, align: 'left' });
currentY += rowHeight;
});
// Ligne de séparation finale
doc.moveTo(60, currentY)
.lineTo(535, currentY)
.stroke('#d1d5db');
// ===== PIED DE PAGE =====
doc.moveDown(2);
doc.fontSize(8)
.fillColor('#9ca3af')
.text(
`Document généré automatiquement le ${new Date().toLocaleDateString('fr-FR')}`,
60,
750,
{ align: 'center', width: 475 }
);
doc.end();
console.log(`✅ PDF moderne généré pour ${collab.nom}`);
} catch (error) {
console.error('❌ Erreur export PDF:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
message: error.message || 'Erreur lors de l\'export PDF'
});
}
}
});
// GET /api/compte-rendu/cumul-annuel - Cumul annuel d'un collaborateur
app.get('/compte-rendu/cumul-annuel', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { user_id, annee } = req.query;
if (!user_id || !annee) {
return res.status(400).json({
success: false,
message: 'Paramètres manquants (user_id, annee)'
});
}
console.log(`📊 Calcul cumul annuel: User=${user_id}, Année=${annee}`);
// Récupérer les données annuelles
const cumulResult = await pool.request()
.input('userId', sql.Int, parseInt(user_id))
.input('annee', sql.Int, parseInt(annee))
.query(`
SELECT
SUM(CAST(NbJoursTravailles AS DECIMAL(10,2))) as totalJoursTravailles,
SUM(NbJoursNonRespectsReposQuotidien) as totalReposQuotidien,
SUM(NbJoursNonRespectsReposHebdo) as totalReposHebdo,
COUNT(CASE WHEN Verrouille = 1 THEN 1 END) as moisValides,
COUNT(*) as moisSaisis
FROM CompteRenduMensuel
WHERE CollaborateurADId = @userId
AND Annee = @annee
`);
const cumul = cumulResult.recordset[0];
res.json({
success: true,
cumul: {
totalJoursTravailles: cumul.totalJoursTravailles || 0,
totalReposQuotidien: cumul.totalReposQuotidien || 0,
totalReposHebdo: cumul.totalReposHebdo || 0,
moisValides: cumul.moisValides || 0,
moisSaisis: cumul.moisSaisis || 0
}
});
} catch (error) {
console.error('❌ Erreur cumul annuel:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
// ================================================
// ROUTE POST /compteurs/saisir-jposf - VERSION AVEC GÉNÉRATION MANUELLE D'ID
// ================================================
app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
console.log('\n📥 ========== DÉBUT SAISIE JPOSF ==========');
await transaction.begin();
if (!['Admin', 'RH'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ success: false, error: 'Accès non autorisé' });
}
const { collaborateur_id, date, periode_journee, commentaire } = req.body;
if (!collaborateur_id || !date || !periode_journee) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Données manquantes' });
}
console.log('📋 Données:', { collaborateur_id, date, periode_journee });
// Récupérer le collaborateur
const collabResult = await transaction.request()
.input('id', sql.Int, parseInt(collaborateur_id))
.query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
if (collabResult.recordset.length === 0) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' });
}
const collab = collabResult.recordset[0];
if (collab.Actif === false || collab.Actif === 0) {
await transaction.rollback();
return res.status(403).json({ success: false, message: 'Compte désactivé' });
}
const currentYear = new Date().getFullYear();
const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5;
// Récupérer les types de congé
const jposfTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'JPOSF'");
if (jposfTypeResult.recordset.length === 0) {
await transaction.rollback();
return res.status(500).json({ success: false, message: 'Type JPOSF non trouvé' });
}
const recupTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
if (recupTypeResult.recordset.length === 0) {
await transaction.rollback();
return res.status(500).json({ success: false, message: 'Type Récupération non trouvé' });
}
const jposfTypeId = jposfTypeResult.recordset[0].Id;
const recupTypeId = recupTypeResult.recordset[0].Id;
console.log(`📋 Types: JPOSF=${jposfTypeId}, Récupération=${recupTypeId}`);
// ⭐ ÉTAPE 1: Mettre à jour le compteur Récupération
const compteurResult = await transaction.request()
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @recupTypeId
AND Annee = @currentYear
`);
let nouveauTotalRecup, nouveauSoldeRecup;
if (compteurResult.recordset.length > 0) {
// ✅ Compteur existe : UPDATE
const compteur = compteurResult.recordset[0];
nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours;
nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours;
await transaction.request()
.input('total', sql.Decimal(10, 2), nouveauTotalRecup)
.input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
.input('id', sql.Int, compteur.Id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
console.log(`✅ Compteur Récup mis à jour: ${nouveauSoldeRecup}j`);
} else {
// ⭐ CORRECTION : Générer l'ID manuellement pour CompteurConges
const maxCompteurIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteurConges');
const compteurId = maxCompteurIdResult.recordset[0].NextId;
console.log(`📝 Nouvel ID CompteurConges: ${compteurId}`);
nouveauTotalRecup = nombreJours;
nouveauSoldeRecup = nombreJours;
// ✅ INSERT avec ID explicite
await transaction.request()
.input('id', sql.Int, compteurId)
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.input('total', sql.Decimal(10, 2), nouveauTotalRecup)
.input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
.query(`
INSERT INTO CompteurConges
(Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@id, @collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
`);
console.log(`✅ Compteur Récup créé: ${nouveauSoldeRecup}j`);
}
// ⭐ ÉTAPE 2: Générer l'ID pour DemandeConge
const maxDemandeIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge');
const demandeId = maxDemandeIdResult.recordset[0].NextId;
console.log(`📝 Nouvel ID DemandeConge: ${demandeId}`);
const commentaireFinal = commentaire?.trim() || `JPOSF - ${periode_journee}`;
// Créer la demande JPOSF
await transaction.request()
.input('id', sql.Int, demandeId)
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('dateDebut', sql.Date, date)
.input('dateFin', sql.Date, date)
.input('commentaire', sql.NVarChar(500), commentaireFinal)
.input('totalJours', sql.Decimal(10, 2), nombreJours)
.input('validateurId', sql.Int, req.user.id)
.query(`
INSERT INTO DemandeConge
(Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
`);
console.log(`✅ DemandeConge créée`);
// ⭐ ÉTAPE 3: Générer l'ID pour DemandeCongeType
const maxTypeIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
const demandeCongeTypeId = maxTypeIdResult.recordset[0].NextId;
console.log(`📝 Nouvel ID DemandeCongeType: ${demandeCongeTypeId}`);
// Lier avec le type JPOSF
await transaction.request()
.input('id', sql.Int, demandeCongeTypeId)
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, jposfTypeId)
.input('nombreJours', sql.Decimal(10, 2), nombreJours)
.query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)');
console.log(`✅ DemandeCongeType créée`);
// Historique (avec gestion d'erreur si la table a IDENTITY ou non)
try {
const maxHistIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
const histId = maxHistIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, histId)
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
.input('demandeId', sql.Int, demandeId)
.query(`
INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@id, @userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
`);
} catch (histError) {
// Fallback sans ID si la table a IDENTITY
console.log('⚠️ Fallback historique sans ID...');
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
.input('demandeId', sql.Int, demandeId)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
`);
}
console.log(`✅ Historique créé`);
await transaction.commit();
console.log('✅ Transaction COMMIT');
// Notifications SSE
// ========== Notifications SSE ==========
try {
// ⭐ CORRECTION: Envoyer les détails complets de la demande pour affichage calendrier
notifyClients({
type: 'demande-validated',
demandeId: demandeId,
statut: 'Validée',
typeConge: 'JPOSF',
couleurHex: '#d946ef',
date: date,
dateDebut: date,
dateFin: date,
periode: periode_journee,
periodeJournee: periode_journee,
nombreJours: nombreJours,
collaborateurId: parseInt(collaborateur_id),
timestamp: new Date().toISOString()
}, parseInt(collaborateur_id));
notifyClients({
type: 'compteur-updated',
collaborateurId: parseInt(collaborateur_id),
typeConge: 'Récupération',
action: 'saisie_jposf_rh',
nouveauSolde: nouveauSoldeRecup,
timestamp: new Date().toISOString()
}, parseInt(collaborateur_id));
console.log('✅ Notifications SSE envoyées avec détails complets pour calendrier');
} catch (sseError) {
console.error('⚠️ Erreur SSE (non bloquant):', sseError.message);
}
// Webhooks
try {
if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, {
demandeId, statut: 'Validée', typeConge: 'JPOSF', collaborateurId: parseInt(collaborateur_id)
});
await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, {
collaborateurId: parseInt(collaborateur_id), typeConge: 'Récupération'
});
}
} catch (webhookError) {
console.error('⚠️ Erreur webhook:', webhookError.message);
}
// Email
try {
const accessToken = await getGraphToken();
if (accessToken && collab.email) {
const subject = '📅 JPOSF enregistré';
const body = `
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<div style="background-color: #8b5cf6; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">📅 JPOSF enregistré</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p>Bonjour <strong>${collab.prenom} ${collab.nom}</strong>,</p>
<div style="background-color: white; border-left: 4px solid #8b5cf6; padding: 15px; margin: 20px 0;">
<p><strong>Date :</strong> ${new Date(date).toLocaleDateString('fr-FR')}</p>
<p><strong>Période :</strong> ${periode_journee}</p>
<p><strong>Jours ajoutés :</strong> ${nombreJours}j</p>
</div>
<p>✅ <strong>+${nombreJours}j</strong> ajoutés au compteur Récupération</p>
</div>
</div>`;
await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab.email, subject, body);
}
} catch (emailError) {
console.error('⚠️ Erreur email:', emailError.message);
}
console.log('✅ ========== JPOSF ENREGISTRÉ ==========\n');
return res.json({
success: true,
message: 'JPOSF enregistré avec succès',
collaborateur: `${collab.prenom} ${collab.nom}`,
date: date,
periode: periode_journee,
jours_ajoutes: nombreJours,
nouveau_solde_recup: nouveauSoldeRecup,
demandeId: demandeId
});
} catch (error) {
console.error('❌ ERREUR JPOSF:', error.message);
console.error('Stack:', error.stack);
try { await transaction.rollback(); } catch (e) { }
return res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// ⭐ ROUTE GET /api/jposf - Ajoutez cette route dans server.js
app.get('/jposf', authenticateToken, async (req, res) => {
try {
const currentYear = new Date().getFullYear();
console.log('📊 GET /api/jposf - Année:', currentYear);
const result = await pool.request()
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT
MIN(dc.Id) AS Id,
dc.CollaborateurADId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
COALESCE(s.Nom, 'Sans service') AS service,
COUNT(DISTINCT dc.Id) AS totalEvenements,
SUM(dct.NombreJours) AS solde
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE tc.Nom = 'JPOSF'
AND dc.Statut = 'Validée'
AND YEAR(dc.DateDebut) = @currentYear
AND (ca.Actif = 1 OR ca.Actif IS NULL)
GROUP BY
dc.CollaborateurADId,
ca.prenom,
ca.nom,
ca.email,
s.Nom
ORDER BY ca.nom, ca.prenom
`);
console.log('📊 JPOSF trouvés:', result.recordset.length);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur GET /api/jposf:', error);
res.status(500).json({ error: error.message });
}
});
// ROUTE GET /demandes avec pagination et filtres
// Dans server.js, remplacer la route GET /demandes
app.get('/demandes', authenticateToken, async (req, res) => {
try {
const { limit, statut, serviceid, campusid } = req.query;
const request = pool.request();
// Filtres WHERE
let whereClause = "WHERE (ca.Actif = 1 OR ca.Actif IS NULL)";
if (statut && statut !== 'all') {
whereClause += " AND dc.Statut = @statut";
request.input('statut', sql.VarChar, statut);
}
if (serviceid && serviceid !== 'all') {
whereClause += " AND ca.ServiceId = @serviceid";
request.input('serviceid', sql.Int, parseInt(serviceid));
}
if (campusid && campusid !== 'all') {
whereClause += " AND ca.CampusId = @campusid";
request.input('campusid', sql.Int, parseInt(campusid));
}
let topClause = '';
if (limit) {
topClause = `TOP ${parseInt(limit)}`;
}
// Requête principale
const query = `
SELECT ${topClause}
dc.Id,
dc.CollaborateurADId,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.Commentaire,
dc.DateDemande,
dc.NombreJours,
dc.ValidateurADId,
dc.DateValidation,
dc.CommentaireValidation,
CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye,
ca.email AS emailEmploye,
s.Nom AS service,
c.Nom AS campus,
-- Agréger les types de congé (pour affichage simple)
STUFF((
SELECT DISTINCT ', ' + tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
ORDER BY ', ' + tc2.Nom
FOR XML PATH('')
), 1, 2, '') AS typesConge
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus c ON ca.CampusId = c.Id
${whereClause}
ORDER BY dc.DateDemande DESC
`;
const result = await request.query(query);
const demandes = result.recordset;
// Récupérer les détails de tous les types de congé
const detailsQuery = `
SELECT
dct.DemandeCongeId,
tc.Nom AS typeConge,
dct.NombreJours AS nombreJours,
dct.PeriodeJournee,
dc.DateDebut AS dateDebut,
dc.DateFin AS dateFin
FROM DemandeCongeType dct
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id
WHERE dct.DemandeCongeId IN (${demandes.map(d => d.Id).join(',') || '0'})
ORDER BY dct.DemandeCongeId, tc.Nom
`;
const detailsResult = await pool.request().query(detailsQuery);
// Grouper les détails par demande
const detailsParDemande = {};
detailsResult.recordset.forEach(detail => {
if (!detailsParDemande[detail.DemandeCongeId]) {
detailsParDemande[detail.DemandeCongeId] = [];
}
detailsParDemande[detail.DemandeCongeId].push({
typeConge: detail.typeConge,
nombreJours: parseFloat(detail.nombreJours),
dateDebut: detail.dateDebut,
dateFin: detail.dateFin
});
});
// Enrichir chaque demande avec ses détails
const demandesEnrichies = demandes.map(demande => ({
Id: demande.Id,
nomEmploye: demande.nomEmploye,
emailEmploye: demande.emailEmploye,
service: demande.service,
Campus: demande.campus,
typesConge: demande.typesConge || 'Non spécifié',
detailsConges: detailsParDemande[demande.Id] || [], // ✅ Toujours présent
DateDebut: demande.DateDebut,
DateFin: demande.DateFin,
NombreJours: demande.NombreJours,
Statut: demande.Statut,
Commentaire: demande.Commentaire,
DateDemande: demande.DateDemande
}));
console.log(`${demandesEnrichies.length} demandes avec détails`);
res.json(demandesEnrichies);
} catch (error) {
console.error('Erreur GET /demandes:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/demandes/:id', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.params.id)
.query(`
SELECT
dc.*,
CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye,
ca.email AS emailEmploye,
ca.Actif AS employeActif,
ca.DateSortie AS employeDateSortie,
s.Nom AS service,
s.Id AS serviceId,
CONCAT(v.prenom, ' ', v.nom) AS nomValidateur,
v.email AS emailValidateur
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id
WHERE dc.Id = @id
`);
const demandes = result.recordset;
if (demandes.length === 0) {
return res.status(404).json({ error: 'Demande non trouvée' });
}
const typesResult = await pool.request()
.input('id', sql.Int, req.params.id)
.query(`
SELECT
dct.TypeCongeId as typeId,
dct.NombreJours as nombreJours,
tc.Nom AS nom,
tc.CouleurHex AS couleur
FROM DemandeCongeType dct
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dct.DemandeCongeId = @id
`);
const types = typesResult.recordset;
res.json({
...demandes[0],
typesConge: types
});
} catch (error) {
console.error('❌ Erreur détails demande:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTE GET /demandes/check-doublon
// Vérifie si un ou plusieurs collaborateurs ont déjà une demande
// (Validée ou En attente) qui chevauche la période demandée.
// Usage: GET /demandes/check-doublon?collaborateurIds=12,45&dateDebut=2026-08-01&dateFin=2026-08-05
// ================================================
app.get('/demandes/check-doublon', authenticateToken, async (req, res) => {
try {
const { collaborateurIds, dateDebut, dateFin } = req.query;
if (!collaborateurIds || !dateDebut || !dateFin) {
return res.json({ success: false, message: 'Paramètres requis: collaborateurIds, dateDebut, dateFin' });
}
const ids = collaborateurIds
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id));
if (ids.length === 0) {
return res.json({ success: false, message: 'Aucun ID collaborateur valide' });
}
const doublonsParCollab = {};
for (const collabId of ids) {
const result = await pool.request()
.input('collabId', sql.Int, collabId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.query(`
SELECT
dc.Id,
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS dateDebut,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS dateFin,
dc.Statut,
dc.NombreJours,
STUFF((
SELECT DISTINCT ', ' + tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
FOR XML PATH('')
), 1, 2, '') AS types
FROM DemandeConge dc
WHERE dc.CollaborateurADId = @collabId
AND dc.Statut IN ('Validée', 'Validé', 'En attente')
AND dc.DateDebut <= @dateFin
AND dc.DateFin >= @dateDebut
ORDER BY dc.DateDebut
`);
if (result.recordset.length > 0) {
doublonsParCollab[collabId] = result.recordset.map(d => ({
id: d.Id,
dateDebut: d.dateDebut,
dateFin: d.dateFin,
statut: d.Statut,
nombreJours: d.NombreJours,
types: d.types
}));
}
}
res.json({
success: true,
hasDoublon: Object.keys(doublonsParCollab).length > 0,
doublons: doublonsParCollab
});
} catch (error) {
console.error('❌ Erreur check-doublon:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ================================================
// ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE
// Remplacer la route existante dans server.js (ligne ~1438)
// ================================================
app.put('/demandes/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const { id } = req.params;
const { dateDebut, dateFin, typesConge, commentaire } = req.body;
console.log('📝 Modification demande:', { id, dateDebut, dateFin, typesConge });
const demandeResult = await transaction.request()
.input('id', sql.Int, id)
.query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id');
const demande = demandeResult.recordset;
if (demande.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Demande non trouvée' });
}
const ancienStatut = demande[0].Statut;
const collaborateurId = demande[0].CollaborateurADId;
const rolesAutorises = ['Admin', 'RH', 'Validateur'];
const estAuteur = collaborateurId === req.user.id;
const estRoleAutorise = rolesAutorises.includes(req.user.role);
if (!estAuteur && !estRoleAutorise) {
await transaction.rollback();
return res.status(403).json({
error: 'Accès non autorisé',
details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande'
});
}
// ⭐ RESTAURER LES COMPTEURS SI LA DEMANDE ÉTAIT VALIDÉE
if (ancienStatut === 'Validée') {
console.log('⚠️ Modification d\'une demande validée - restauration des compteurs');
const ancienTypesResult = await transaction.request()
.input('id', sql.Int, id)
.query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id');
const ancienTypes = ancienTypesResult.recordset;
const currentYear = new Date().getFullYear();
for (const type of ancienTypes) {
// ⭐ Parser correctement les décimales
const nombreJoursARestaurer = parseFloat(type.NombreJours);
// Restaurer avec priorité CP N-1 puis CP N
if (type.TypeCongeId === 1) {
console.log(`\n🔄 Restauration CP - ${nombreJoursARestaurer}j à restaurer`);
let joursRestantsARestaurer = nombreJoursARestaurer;
// D'abord : Restaurer dans CP N
const cpNResult = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeId', sql.Int, type.TypeCongeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Solde, Total
FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeId
AND Annee = @currentYear
`);
if (cpNResult.recordset.length > 0) {
const compteurN = cpNResult.recordset[0];
const soldeN = parseFloat(compteurN.Solde || 0);
const totalN = parseFloat(compteurN.Total || 0);
const aRestaurerN = Math.min(joursRestantsARestaurer, totalN - soldeN);
if (aRestaurerN > 0) {
await transaction.request()
.input('restauration', sql.Decimal(10, 2), aRestaurerN)
.input('compteurId', sql.Int, compteurN.Id)
.query(`
UPDATE CompteurConges
SET Solde = Solde + @restauration,
DerniereMiseAJour = GETDATE()
WHERE Id = @compteurId
`);
console.log(` ✅ CP N (${currentYear}) : +${aRestaurerN}j restaurés`);
joursRestantsARestaurer -= aRestaurerN;
}
}
// Ensuite : Si besoin, restaurer dans CP N-1
if (joursRestantsARestaurer > 0) {
console.log(` 🔄 Il reste ${joursRestantsARestaurer}j, restauration dans CP N-1`);
const cpN1Result = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeId', sql.Int, type.TypeCongeId)
.input('previousYear', sql.Int, currentYear - 1)
.query(`
SELECT Id, Solde, Total
FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeId
AND Annee = @previousYear
`);
if (cpN1Result.recordset.length > 0) {
const compteurN1 = cpN1Result.recordset[0];
const soldeN1 = parseFloat(compteurN1.Solde || 0);
const totalN1 = parseFloat(compteurN1.Total || 0);
const aRestaurerN1 = Math.min(joursRestantsARestaurer, totalN1 - soldeN1);
if (aRestaurerN1 > 0) {
await transaction.request()
.input('restauration', sql.Decimal(10, 2), aRestaurerN1)
.input('compteurId', sql.Int, compteurN1.Id)
.query(`
UPDATE CompteurConges
SET Solde = Solde + @restauration,
DerniereMiseAJour = GETDATE()
WHERE Id = @compteurId
`);
console.log(` ✅ CP N-1 (${currentYear - 1}) : +${aRestaurerN1}j restaurés`);
}
}
}
} else {
// Autres types : restauration simple
await transaction.request()
.input('nombreJours', sql.Decimal(10, 2), nombreJoursARestaurer)
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeCongeId', sql.Int, type.TypeCongeId)
.query(`
UPDATE CompteurConges
SET Solde = Solde + @nombreJours,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeCongeId
AND Annee = YEAR(GETDATE())
`);
console.log(` ✅ Type ${type.TypeCongeId} : +${nombreJoursARestaurer}j restaurés`);
}
}
}
// ⭐ Calculer le total avec support des décimales
const totalJours = typesConge.reduce((sum, type) => {
return sum + parseFloat(type.nombreJours || 0);
}, 0);
const nouveauStatut = 'Validée';
console.log(`📝 Mise à jour demande ${id} - Nouveau statut: ${nouveauStatut}`);
// Mise à jour de la demande
await transaction.request()
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.input('commentaire', sql.NVarChar, commentaire || '')
.input('totalJours', sql.Decimal(10, 2), totalJours)
.input('nouveauStatut', sql.VarChar, nouveauStatut)
.input('validateurId', sql.Int, req.user.id)
.input('id', sql.Int, id)
.query(`
UPDATE DemandeConge
SET DateDebut = @dateDebut,
DateFin = @dateFin,
Commentaire = @commentaire,
NombreJours = @totalJours,
Statut = @nouveauStatut,
ValidateurADId = @validateurId,
DateValidation = GETDATE(),
CommentaireValidation = 'Validation automatique après modification'
WHERE Id = @id
`);
// Supprimer les anciens types
await transaction.request()
.input('id', sql.Int, id)
.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id');
console.log('✅ Anciens types supprimés');
// Recréer les types avec génération manuelle d'ID
for (const type of typesConge) {
const maxTypeIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
const typeId = maxTypeIdResult.recordset[0].NextId;
// ⭐ Parser correctement le nombre de jours
const nombreJours = parseFloat(type.nombreJours || 0);
await transaction.request()
.input('id', sql.Int, typeId)
.input('demandeId', sql.Int, id)
.input('typeId', sql.Int, type.typeId)
.input('nombreJours', sql.Decimal(10, 2), nombreJours)
.query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)');
console.log(`✅ Type ${type.typeId} recréé avec ID ${typeId} - ${nombreJours}j`);
}
// ⭐ DÉDUIRE LES NOUVEAUX COMPTEURS (AVEC PRIORITÉ CP N-1 puis CP N)
console.log('💼 Déduction des nouveaux compteurs avec priorité CP N-1...');
const currentYear = new Date().getFullYear();
for (const type of typesConge) {
const nombreJours = parseFloat(type.nombreJours || 0);
// SI C'EST UN TYPE CP (ID = 1), GÉRER LA PRIORITÉ CP N-1 PUIS CP N
if (type.typeId === 1) {
console.log(`\n🔄 Traitement CP - ${nombreJours}j à déduire`);
let joursRestantsADeduire = nombreJours;
// 1️⃣ D'ABORD : Pioche dans CP N-1
const cpN1Result = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeId', sql.Int, type.typeId)
.input('previousYear', sql.Int, currentYear - 1)
.query(`
SELECT Id, Solde
FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeId
AND Annee = @previousYear
`);
if (cpN1Result.recordset.length > 0) {
const compteurN1 = cpN1Result.recordset[0];
const soldeN1 = parseFloat(compteurN1.Solde || 0);
if (soldeN1 > 0) {
const aDeduireN1 = Math.min(soldeN1, joursRestantsADeduire);
await transaction.request()
.input('deduction', sql.Decimal(10, 2), aDeduireN1)
.input('compteurId', sql.Int, compteurN1.Id)
.query(`
UPDATE CompteurConges
SET Solde = Solde - @deduction,
DerniereMiseAJour = GETDATE()
WHERE Id = @compteurId
`);
console.log(` ✅ CP N-1 (${currentYear - 1}) : -${aDeduireN1}j`);
joursRestantsADeduire -= aDeduireN1;
}
}
// 2️⃣ ENSUITE : S'il reste des jours, pioche dans CP N
if (joursRestantsADeduire > 0) {
console.log(` 🔄 Il reste ${joursRestantsADeduire}j, pioche dans CP N`);
const cpNResult = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeId', sql.Int, type.typeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Solde
FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeId
AND Annee = @currentYear
`);
if (cpNResult.recordset.length > 0) {
const compteurN = cpNResult.recordset[0];
await transaction.request()
.input('deduction', sql.Decimal(10, 2), joursRestantsADeduire)
.input('compteurId', sql.Int, compteurN.Id)
.query(`
UPDATE CompteurConges
SET Solde = Solde - @deduction,
DerniereMiseAJour = GETDATE()
WHERE Id = @compteurId
`);
console.log(` ✅ CP N (${currentYear}) : -${joursRestantsADeduire}j`);
}
}
} else {
// Pour les autres types (RTT, Récupération, etc.)
await transaction.request()
.input('nombreJours', sql.Decimal(10, 2), nombreJours)
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeId', sql.Int, type.typeId)
.query(`
UPDATE CompteurConges
SET Solde = Solde - @nombreJours,
DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeId
AND Annee = YEAR(GETDATE())
`);
console.log(` ✅ Type ${type.typeId} : -${nombreJours}j`);
}
}
const actionDetails = `Modification et validation automatique par ${req.user.role}`;
// Historique
try {
const maxHistIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
const histId = maxHistIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, histId)
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, actionDetails)
.input('demandeId', sql.Int, id)
.query(`
INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@id, @userId, 'Modification et validation', @details, @demandeId, GETDATE())
`);
} catch (histError) {
console.log('⚠️ Fallback historique sans ID...');
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, actionDetails)
.input('demandeId', sql.Int, id)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@userId, 'Modification et validation', @details, @demandeId, GETDATE())
`);
}
await transaction.commit();
console.log('✅ Modification et validation terminées');
// Notifications SSE
notifyClients({
type: 'demande-validated',
demandeId: parseInt(id),
statut: 'Validée',
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
}, collaborateurId);
notifyClients({
type: 'demande-updated',
demandeId: parseInt(id),
action: 'modification',
nouveauStatut: nouveauStatut,
ancienStatut: ancienStatut,
timestamp: new Date().toISOString()
}, collaborateurId);
notifyClients({
type: 'demande-list-updated',
action: 'modification',
demandeId: parseInt(id),
timestamp: new Date().toISOString()
});
// Webhooks
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_VALIDATED,
{
demandeId: parseInt(id),
statut: 'Validée',
collaborateurId: collaborateurId,
validateurId: req.user.id
}
);
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId: collaborateurId }
);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
res.json({
message: 'Demande modifiée et validée automatiquement',
nouveauStatut: nouveauStatut,
ancienStatut: ancienStatut
});
console.log(`✅ Demande ${id} modifiée et validée par ${req.user.role}`);
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur modification demande:', error);
res.status(500).json({ error: error.message });
}
});
app.delete('/demandes/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const { id } = req.params;
const demandeResult = await transaction.request()
.input('id', sql.Int, id)
.query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id');
const demande = demandeResult.recordset;
if (demande.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Demande non trouvée' });
}
const collaborateurId = demande[0].CollaborateurADId;
const rolesAutorises = ['Admin', 'RH', 'Validateur'];
const estAuteur = collaborateurId === req.user.id;
const estRoleAutorise = rolesAutorises.includes(req.user.role);
if (!estAuteur && !estRoleAutorise) {
await transaction.rollback();
return res.status(403).json({
error: 'Accès non autorisé',
details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent supprimer cette demande'
});
}
// Supprimer d'abord les entrées d'historique
await transaction.request()
.input('id', sql.Int, id)
.query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = @id');
// Supprimer les types de congé
await transaction.request()
.input('id', sql.Int, id)
.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id');
// Supprimer la demande
await transaction.request()
.input('id', sql.Int, id)
.query('DELETE FROM DemandeConge WHERE Id = @id');
await transaction.commit();
notifyClients({
type: 'demande-deleted',
demandeId: parseInt(id),
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
});
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_DELETED,
{
demandeId: parseInt(id),
collaborateurId: collaborateurId
}
);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
console.log(`✅ Demande ${id} supprimée par ${req.user.role}`);
res.json({ message: 'Demande supprimée avec succès' });
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur suppression demande:', error);
res.status(500).json({ error: error.message });
}
});
// ⭐ ROUTE VALIDATION AVEC WEBHOOKS
// ⭐ ROUTE VALIDATION AVEC WEBHOOKS - CORRIGÉE
app.put('/demandes/:id/valider', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
const { id } = req.params;
const { statut, commentaire } = req.body;
console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ===');
const demandeInfoResult = await transaction.request()
.input('id', sql.Int, id)
.query(`
SELECT
dc.Id,
dc.CollaborateurADId,
dc.DateDebut,
dc.DateFin,
dc.NombreJours,
ca.prenom,
ca.nom,
ca.email as collaborateur_email,
ca.Actif,
STRING_AGG(tc.Nom, ', ') as TypeConge
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Id = @id
GROUP BY dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours,
ca.prenom, ca.nom, ca.email, ca.Actif
`);
const demandeInfo = demandeInfoResult.recordset;
if (demandeInfo.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Demande non trouvée' });
}
const demande = demandeInfo[0];
const collaborateurId = demande.CollaborateurADId;
await transaction.request()
.input('statut', sql.VarChar, statut)
.input('commentaire', sql.NVarChar, commentaire || '')
.input('validateurId', sql.Int, req.user.id)
.input('id', sql.Int, id)
.query(`
UPDATE DemandeConge
SET Statut = @statut,
CommentaireValidation = @commentaire,
ValidateurADId = @validateurId,
DateValidation = GETDATE()
WHERE Id = @id
`);
if (statut === 'Validée') {
const typesResult = await transaction.request()
.input('id', sql.Int, id)
.query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id');
const types = typesResult.recordset;
const currentYear = new Date().getFullYear();
for (const type of types) {
await transaction.request()
.input('nombreJours', sql.Decimal(10, 2), type.NombreJours)
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeCongeId', sql.Int, type.TypeCongeId)
.query(`
UPDATE CompteurConges
SET Solde = Solde - @nombreJours
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeCongeId
AND Annee = YEAR(GETDATE())
`);
// ✅ AJOUT : Insérer dans DeductionDetails pour cohérence avec /annuler
await transaction.request()
.input('demandeId', sql.Int, id)
.input('typeId', sql.Int, type.TypeCongeId)
.input('annee', sql.Int, currentYear)
.input('jours', sql.Decimal(10, 2), type.NombreJours)
.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)
`);
}
}
// ⭐ CORRECTION: Génération manuelle de l'ID pour HistoriqueActions
try {
const maxHistIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
const histId = maxHistIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, histId)
.input('userId', sql.Int, req.user.id)
.input('action', sql.VarChar, statut === 'Validée' ? 'Validation congé' : 'Refus congé')
.input('commentaire', sql.NVarChar, commentaire || '')
.input('demandeId', sql.Int, id)
.query(`
INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@id, @userId, @action, @commentaire, @demandeId, GETDATE())
`);
console.log(`✅ Historique validation créé avec ID: ${histId}`);
} catch (histError) {
console.error('⚠️ Erreur historique (non bloquant):', histError.message);
}
await transaction.commit();
// ⭐ ENVOI EMAIL
const accessToken = await getGraphToken();
if (accessToken && demande.collaborateur_email) {
const fromEmail = 'noreply@ensup.eu';
const collaborateurNom = `${demande.prenom} ${demande.nom}`;
const validateurNom = req.user.prenom && req.user.nom
? `${req.user.prenom} ${req.user.nom}`
: 'l\'équipe RH';
const dateDebut = new Date(demande.DateDebut).toLocaleDateString('fr-FR');
const dateFin = new Date(demande.DateFin).toLocaleDateString('fr-FR');
const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`;
const subject = statut === 'Validée'
? '✅ Votre demande de congé a été approuvée'
: '❌ Votre demande de congé a été refusée';
const body = statut === 'Validée'
? `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #10b981; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">✅ Demande approuvée</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p style="font-size: 16px;">Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Votre demande de congé a été <strong style="color: #10b981;">approuvée</strong> par ${validateurNom}.</p>
<div style="background-color: white; border-left: 4px solid #10b981; padding: 15px; margin: 20px 0;">
<p><strong>Type :</strong> ${demande.TypeConge}</p>
<p><strong>Période :</strong> ${datesPeriode}</p>
<p><strong>Durée :</strong> ${demande.NombreJours} jour(s)</p>
${commentaire ? `<p><strong>Commentaire :</strong> ${commentaire}</p>` : ''}
</div>
</div>
</div>`
: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #ef4444; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">❌ Demande refusée</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p style="font-size: 16px;">Bonjour <strong>${collaborateurNom}</strong>,</p>
<p>Votre demande de congé a été <strong style="color: #ef4444;">refusée</strong> par ${validateurNom}.</p>
<div style="background-color: white; border-left: 4px solid #ef4444; padding: 15px; margin: 20px 0;">
<p><strong>Type :</strong> ${demande.TypeConge}</p>
<p><strong>Période :</strong> ${datesPeriode}</p>
<p><strong>Durée :</strong> ${demande.NombreJours} jour(s)</p>
${commentaire ? `<p><strong>Motif du refus :</strong> ${commentaire}</p>` : ''}
</div>
</div>
</div>`;
await sendMailGraph(accessToken, fromEmail, demande.collaborateur_email, subject, body);
}
notifyClients({
type: 'demande-validated',
demandeId: parseInt(id),
statut: statut,
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
}, collaborateurId);
notifyClients({
type: 'demande-list-updated',
action: 'validation',
demandeId: parseInt(id),
timestamp: new Date().toISOString()
});
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_VALIDATED,
{
demandeId: parseInt(id),
statut: statut,
collaborateurId: collaborateurId,
validateurId: req.user.id,
commentaire: commentaire
}
);
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId: collaborateurId }
);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
res.json({ message: 'Demande mise à jour' });
} catch (error) {
await transaction.rollback();
console.error('\n❌ ERREUR VALIDATION:', error);
res.status(500).json({ error: error.message });
}
});
// Route pour export paie par plage de dates
app.get('/export/paie-plage', authenticateToken, async (req, res) => {
const { dateDebut, dateFin } = req.query;
if (!dateDebut || !dateFin) {
return res.status(400).json({ error: 'Date de début et date de fin requises' });
}
console.log(`📊 Export paie - Plage: ${dateDebut} au ${dateFin}`);
try {
const debut = new Date(dateDebut);
const fin = new Date(dateFin);
if (isNaN(debut.getTime()) || isNaN(fin.getTime())) {
return res.status(400).json({ error: 'Dates invalides' });
}
if (debut > fin) {
return res.status(400).json({ error: 'La date de début doit être antérieure à la date de fin' });
}
const result = await pool.request()
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.query(`
SELECT
ca.id AS collaborateurId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
COALESCE(s.Nom, 'Non défini') AS service,
tc.Nom AS typeConge,
dct.NombreJours,
dct.PeriodeJournee,
-- ⭐ Dates précises du TYPE de congé
COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
dc.DateDebut,
dc.DateFin,
dc.Statut
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Statut = 'Validée'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
AND (
(COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @dateDebut AND @dateFin)
OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @dateDebut AND @dateFin)
OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @dateDebut AND COALESCE(dct.DateFin, dc.DateFin) >= @dateFin)
)
ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut)
`);
console.log(`📊 ${result.recordset.length} entrées trouvées`);
const dataFormatted = agregerLignesParEmploye(result.recordset);
console.log(`✅ Export paie-plage formaté: ${dataFormatted.length} employés`);
res.json(dataFormatted);
} catch (error) {
console.error('❌ Erreur export paie plage:', error);
res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message });
}
});
// ================================================
// ROUTE GET /export/paie-saisie
// Filtre par date de SAISIE de la demande (DateDemande)
// À ajouter dans server.js après la route /export/paie-plage
// ================================================
app.get('/export/paie-saisie', authenticateToken, async (req, res) => {
const { dateSaisieDebut, dateSaisieFin } = req.query;
console.log(`📊 Export paie-saisie: saisie entre ${dateSaisieDebut} et ${dateSaisieFin}`);
if (!dateSaisieDebut || !dateSaisieFin) {
return res.status(400).json({ error: 'Date de saisie début et fin requises' });
}
try {
// ✅ DEBUG : voir ce qu'il y a en base pour juin
const debug = await pool.request()
.input('dateSaisieDebut', sql.Date, dateSaisieDebut)
.input('dateSaisieFin', sql.Date, dateSaisieFin)
.query(`
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
dc.DateDemande,
dc.DateDebut,
dc.DateFin,
COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
tc.Nom AS typeConge,
dc.Statut
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Statut = 'Validée'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin
AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut
`);
console.log('🔍 DEBUG - Résultats sans filtre saisie:');
debug.recordset.forEach(r => {
console.log(` 👤 ${r.employe} | Saisie: ${r.DateDemande} | Congé: ${r.DateDebut}${r.DateFin} | Type dates: ${r.dateDebutType}${r.dateFinType} | Type: ${r.typeConge}`);
});
const result = await pool.request()
.input('dateSaisieDebut', sql.Date, dateSaisieDebut)
.input('dateSaisieFin', sql.Date, dateSaisieFin)
.query(`
SELECT
ca.id AS collaborateurId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
COALESCE(s.Nom, 'Non défini') AS service,
tc.Nom AS typeConge,
dct.NombreJours,
dct.PeriodeJournee,
COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Statut = 'Validée'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin
AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut
AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin
ORDER BY ca.nom, ca.prenom,
COALESCE(dct.DateDebut, dc.DateDebut)
`);
console.log(`📊 ${result.recordset.length} entrées trouvées`);
const dataFormatted = agregerLignesParEmploye(result.recordset);
console.log(`✅ Export paie-saisie formaté: ${dataFormatted.length} employés`);
res.json(dataFormatted);
} catch (error) {
console.error('❌ Erreur export paie-saisie:', error);
res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message });
}
});
// ================================================
// ROUTES EMPLOYÉS
// ================================================
app.get('/employes', authenticateToken, async (req, res) => {
try {
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const result = await pool.request()
.input('currentYear', sql.Int, currentYear)
.input('previousYear', sql.Int, previousYear)
.query(`
SELECT
ca.id,
ca.nom,
ca.prenom,
ca.email,
ca.role,
COALESCE(s.Nom, 'Sans service') as service,
ca.Actif,
ca.TypeContrat,
-- CP Année N
(SELECT TOP 1 COALESCE(Solde, 0)
FROM CompteurConges cc1
WHERE cc1.CollaborateurADId = ca.id
AND cc1.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id)
AND cc1.Annee = @currentYear) as soldeCPN,
-- CP Année N-1
(SELECT TOP 1 COALESCE(Solde, 0)
FROM CompteurConges cc2
WHERE cc2.CollaborateurADId = ca.id
AND cc2.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id)
AND cc2.Annee = @previousYear) as soldeCPNMoins1,
-- RTT
(SELECT TOP 1 COALESCE(Solde, 0)
FROM CompteurConges cc3
WHERE cc3.CollaborateurADId = ca.id
AND cc3.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'RTT' ORDER BY Id)
AND cc3.Annee = @currentYear) as soldeRTT,
-- Récupération
(SELECT TOP 1 COALESCE(Solde, 0)
FROM CompteurConges cc4
WHERE cc4.CollaborateurADId = ca.id
AND cc4.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Récup%' OR Nom LIKE '%Recup%' ORDER BY Id)
AND cc4.Annee = @currentYear) as soldeRecup
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
WHERE ca.Actif = 1 OR ca.Actif IS NULL
ORDER BY ca.nom, ca.prenom
`);
console.log(`${result.recordset.length} employés récupérés avec compteurs`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /api/employes:', error);
res.status(500).json({ error: error.message });
}
});
// ✅ NOUVELLE ROUTE: Désactiver un collaborateur
app.post('/employes/desactiver', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateur_id, date_sortie } = req.body;
if (!collaborateur_id) {
return res.json({ success: false, message: 'ID collaborateur manquant' });
}
const dateSortie = date_sortie || new Date().toISOString().split('T')[0];
const collabResult = await pool.request()
.input('id', sql.Int, collaborateur_id)
.query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
const collab = collabResult.recordset;
if (collab.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
await pool.request()
.input('dateSortie', sql.Date, dateSortie)
.input('id', sql.Int, collaborateur_id)
.query('UPDATE CollaborateurAD SET Actif = 0, DateSortie = @dateSortie WHERE id = @id');
res.json({
success: true,
message: 'Collaborateur désactivé',
nom: `${collab[0].prenom} ${collab[0].nom}`,
date_sortie: dateSortie
});
} catch (error) {
console.error('Erreur desactiverCollaborateur:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
// ✅ NOUVELLE ROUTE: Réactiver un collaborateur
app.post('/employes/reactiver', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateur_id } = req.body;
if (!collaborateur_id) {
return res.json({ success: false, message: 'ID collaborateur manquant' });
}
const collabResult = await pool.request()
.input('id', sql.Int, collaborateur_id)
.query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
const collab = collabResult.recordset;
if (collab.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
await pool.request()
.input('id', sql.Int, collaborateur_id)
.query('UPDATE CollaborateurAD SET Actif = 1, DateSortie = NULL WHERE id = @id');
res.json({
success: true,
message: 'Collaborateur réactivé',
nom: `${collab[0].prenom} ${collab[0].nom}`
});
} catch (error) {
console.error('Erreur reactiverCollaborateur:', error);
res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
}
});
app.get('/types-conge', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(
'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom'
);
res.json(result.recordset);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Suite - Routes export, compteurs, équipes, stats, etc.
app.get('/export/paie', authenticateToken, async (req, res) => {
try {
const { mois, annee, dateSaisieDebut, dateSaisieFin } = req.query;
const premierJour = `${annee}-${String(mois).padStart(2, '0')}-01`;
const dernierJour = new Date(annee, mois, 0).getDate();
const dernierJourMois = `${annee}-${String(mois).padStart(2, '0')}-${dernierJour}`;
// Le filtre création est actif uniquement si les deux dates sont fournies
const filtreCreationActif = dateSaisieDebut && dateSaisieFin;
console.log(`📊 Export paie: ${mois}/${annee} (${premierJour} -> ${dernierJourMois})${filtreCreationActif ? ` | Filtre création: ${dateSaisieDebut} -> ${dateSaisieFin}` : ''}`);
const request = pool.request()
.input('premierJour', sql.Date, premierJour)
.input('dernierJourMois', sql.Date, dernierJourMois);
// Construire la clause WHERE du filtre de création
let whereCreation = '';
if (filtreCreationActif) {
request
.input('dateSaisieDebut', sql.Date, dateSaisieDebut)
.input('dateSaisieFin', sql.Date, dateSaisieFin);
whereCreation = `AND CAST(dc.DateDemande AS DATE) >= @dateSaisieDebut
AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin`;
}
const result = await request.query(`
SELECT
ca.id AS collaborateurId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
COALESCE(s.Nom, 'Non assigné') AS service,
tc.Nom AS typeConge,
dct.NombreJours,
dct.PeriodeJournee,
-- Dates précises du TYPE de congé
COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
-- Dates de la demande (pour le filtre période)
dc.DateDebut,
dc.DateFin,
dc.Statut
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.Statut = 'Validée'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
AND (
-- La période du TYPE chevauche le mois
(COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @premierJour AND @dernierJourMois)
OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @premierJour AND @dernierJourMois)
OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @premierJour AND COALESCE(dct.DateFin, dc.DateFin) >= @dernierJourMois)
)
${whereCreation}
ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut)
`);
console.log(`📊 ${result.recordset.length} entrées trouvées`);
const dataFormatted = agregerLignesParEmploye(result.recordset);
console.log(`✅ Export formaté: ${dataFormatted.length} employés`);
res.json(dataFormatted);
} catch (error) {
console.error('❌ Erreur export paie:', error);
res.status(500).json({ error: error.message });
}
});
// Route pour obtenir les informations d'un collaborateur
app.get('/employes/:id/info', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.params.id)
.query(`
SELECT
id,
nom,
prenom,
email,
role,
TypeContrat,
Actif,
DateSortie,
ServiceId
FROM CollaborateurAD
WHERE id = @id
`);
const employe = result.recordset;
if (employe.length === 0) {
return res.status(404).json({ error: 'Employé non trouvé' });
}
const collab = employe[0];
// Déterminer si c'est un apprenti
const estApprenti = collab.TypeContrat === 'Apprentissage' ||
collab.role === 'Apprenti' ||
collab.role?.toLowerCase().includes('apprenti');
res.json({
...collab,
estApprenti,
droitRTT: !estApprenti
});
} catch (error) {
console.error('Erreur récupération info employé:', error);
res.status(500).json({ error: error.message });
}
});
// ✅ Modifier la route GET /compteurs pour inclure les infos sur les apprentis
app.get('/compteurs', authenticateToken, async (req, res) => {
try {
const includeInactifs = req.query.include_inactifs === 'true';
const { user_id } = req.query;
let query = `
SELECT
cc.Id AS id,
cc.CollaborateurADId AS collaborateurId,
cc.TypeCongeId AS typeCongeId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email AS email,
ca.Actif AS Actif,
ca.role AS role,
ca.TypeContrat AS TypeContrat,
COALESCE(s.Nom, 'Sans service') AS service,
tc.Nom AS typeConge,
cc.Annee AS annee,
cc.Total AS total,
cc.Solde AS solde,
cc.SoldeReporte AS soldeReporte,
(cc.Total - cc.Solde) AS consomme
FROM CompteurConges cc
JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE 1=1
AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%')
`;
const request = pool.request();
// Filtre par utilisateur si fourni
if (user_id) {
query += ' AND cc.CollaborateurADId = @userId';
request.input('userId', sql.Int, parseInt(user_id));
}
// Filtre actif/inactif
if (!includeInactifs) {
query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)';
}
query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom';
const result = await request.query(query);
const compteurs = result.recordset;
console.log(`${compteurs.length} compteurs trouvés`);
// ⭐ DEBUG: Afficher les types de congés trouvés
const typesConges = [...new Set(compteurs.map(c => c.typeConge))];
console.log('📊 Types de congés:', typesConges);
// Ajouter l'indicateur apprenti et formater les données
const compteursFormates = compteurs.map(c => ({
id: c.id,
collaborateurId: c.collaborateurId,
typeCongeId: c.typeCongeId,
employe: c.employe,
email: c.email,
service: c.service || 'Sans service',
typeConge: c.typeConge,
annee: c.annee,
total: parseFloat(c.total) || 0,
solde: parseFloat(c.solde) || 0,
soldeReporte: parseFloat(c.soldeReporte) || 0,
consomme: parseFloat(c.consomme) || 0,
estApprenti: c.TypeContrat === 'Apprentissage' ||
c.role === 'Apprenti' ||
(c.role || '').toLowerCase().includes('apprenti')
}));
res.json(compteursFormates);
} catch (error) {
console.error('❌ Erreur récupération compteurs:', error);
res.status(500).json({ error: error.message });
}
});
app.put('/compteurs/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { id } = req.params;
const { total, solde } = req.body;
const totalNum = parseFloat(total);
const soldeNum = parseFloat(solde);
const existingResult = await transaction.request()
.input('id', sql.Int, id)
.query('SELECT * FROM CompteurConges WHERE Id = @id');
const existing = existingResult.recordset;
if (existing.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Compteur non trouvé' });
}
const collaborateurId = existing[0].CollaborateurADId;
const typeCongeId = existing[0].TypeCongeId;
const annee = existing[0].Annee;
// Récupérer le nom du type de congé
const typeInfoResult = await transaction.request()
.input('typeCongeId', sql.Int, typeCongeId)
.query('SELECT Nom FROM TypeConge WHERE Id = @typeCongeId');
const typeConge = typeInfoResult.recordset.length > 0 ? typeInfoResult.recordset[0].Nom : 'Inconnu';
// Mise à jour
await transaction.request()
.input('total', sql.Decimal(10, 2), totalNum)
.input('solde', sql.Decimal(10, 2), soldeNum)
.input('id', sql.Int, id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
await transaction.commit();
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeCongeId: typeCongeId,
typeConge: typeConge,
annee: annee,
action: 'modification_rh',
nouveauTotal: totalNum,
nouveauSolde: soldeNum,
timestamp: new Date().toISOString()
}, collaborateurId);
// Webhook
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: collaborateurId,
typeCongeId: typeCongeId,
typeConge: typeConge,
typeUpdate: 'modification_manuelle_rh',
annee: annee,
nouveauTotal: totalNum,
nouveauSolde: soldeNum,
source: 'rh',
timestamp: new Date().toISOString()
}
);
console.log(`✅ Webhook envoyé pour ${collaborateurId} (${typeConge} ${annee}: Total=${totalNum}j, Solde=${soldeNum}j)`);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
res.json({
message: 'Compteur modifié avec succès',
total: totalNum,
solde: soldeNum
});
} catch (error) {
await transaction.rollback();
console.error('Erreur modification compteur:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTE POUR AJOUTER DES JOURS DE RÉCUPÉRATION
// ================================================
app.post('/compteurs/ajouter-recup', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
if (!['Admin', 'RH'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateurId, nombreJours, commentaire } = req.body;
if (!collaborateurId || !nombreJours || nombreJours <= 0) {
await transaction.rollback();
return res.json({
success: false,
message: 'Données invalides'
});
}
console.log('📥 Ajout de récupération:', { collaborateurId, nombreJours, commentaire });
// Récupérer les infos du collaborateur
const collaborateurResult = await transaction.request()
.input('id', sql.Int, collaborateurId)
.query('SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
const collaborateur = collaborateurResult.recordset;
if (collaborateur.length === 0) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Collaborateur non trouvé'
});
}
if (collaborateur[0].Actif === 0) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'Impossible d\'ajouter des jours à un compte désactivé'
});
}
const collab = collaborateur[0];
const currentYear = new Date().getFullYear();
// Récupérer l'ID du type "Récupération"
const recupTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
const recupType = recupTypeResult.recordset;
if (recupType.length === 0) {
await transaction.rollback();
return res.status(500).json({
success: false,
message: 'Type de congé "Récupération" non trouvé'
});
}
const recupTypeId = recupType[0].Id;
// Vérifier si le compteur existe déjà
const compteurExistingResult = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @recupTypeId
AND Annee = @currentYear
`);
const compteurExisting = compteurExistingResult.recordset;
let nouveauTotal, nouveauSolde;
if (compteurExisting.length > 0) {
nouveauTotal = parseFloat(compteurExisting[0].Total) + nombreJours;
nouveauSolde = parseFloat(compteurExisting[0].Solde) + nombreJours;
// Mettre à jour le compteur existant
await transaction.request()
.input('total', sql.Decimal(10, 2), nouveauTotal)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.input('id', sql.Int, compteurExisting[0].Id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${nouveauSolde.toFixed(2)}j)`);
} else {
nouveauTotal = nombreJours;
nouveauSolde = nombreJours;
// Créer un nouveau compteur
await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.input('total', sql.Decimal(10, 2), nouveauTotal)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
`);
console.log(`✅ Compteur Récupération créé: ${nombreJours}j`);
}
// Enregistrer dans l'historique
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, `Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (@userId, 'Ajout récupération', @details, GETDATE())
`);
await transaction.commit();
// Webhook
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: collaborateurId,
typeCongeId: recupTypeId,
typeConge: 'Récupération',
typeUpdate: 'ajout_manuel_rh',
annee: currentYear,
nouveauTotal: nouveauTotal,
nouveauSolde: nouveauSolde,
source: 'bouton_plus_rh',
timestamp: new Date().toISOString()
}
);
console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)');
} catch (webhookError) {
console.error('❌ Erreur webhook (non bloquant):', webhookError.message);
}
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeConge: 'Récupération',
action: 'ajout',
nombreJours: nombreJours,
timestamp: new Date().toISOString()
}, collaborateurId);
// Envoyer un email
const accessToken = await getGraphToken();
if (accessToken && collab.email) {
const fromEmail = 'noreply@ensup.eu';
const subject = '✅ Jours de récupération ajoutés';
const body = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #8b5cf6; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">✅ Jours de récupération ajoutés</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p style="font-size: 16px;">Bonjour <strong>${collab.prenom} ${collab.nom}</strong>,</p>
<p>Des jours de récupération ont été ajoutés à votre compteur.</p>
<div style="background-color: white; border-left: 4px solid #8b5cf6; padding: 15px; margin: 20px 0;">
<p><strong>Jours ajoutés :</strong> ${nombreJours} jour${nombreJours > 1 ? 's' : ''}</p>
${commentaire ? `<p><strong>Motif :</strong> ${commentaire}</p>` : ''}
<p><strong>Année :</strong> ${currentYear}</p>
</div>
<p style="color: #6b7280;">Ces jours sont désormais disponibles et peuvent être utilisés via une demande de congé.</p>
</div>
</div>
`;
try {
await sendMailGraph(accessToken, fromEmail, collab.email, subject, body);
console.log('✅ Email de notification envoyé');
} catch (emailError) {
console.error('❌ Erreur envoi email:', emailError);
}
}
res.json({
success: true,
message: 'Jours de récupération ajoutés avec succès',
collaborateur: `${collab.prenom} ${collab.nom}`,
nombreJours: nombreJours,
annee: currentYear
});
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur ajout récupération:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// Suite - Routes équipes, stats, historique
// ================================================
// ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION
// ================================================
app.post('/compteurs/retirer-recup', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
if (!['Admin', 'RH'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateurId, nombreJours, commentaire } = req.body;
if (!collaborateurId || !nombreJours || nombreJours <= 0) {
await transaction.rollback();
return res.json({
success: false,
message: 'Données invalides'
});
}
console.log('📤 Retrait de récupération:', { collaborateurId, nombreJours, commentaire });
// Récupérer les infos du collaborateur
const collaborateurResult = await transaction.request()
.input('id', sql.Int, collaborateurId)
.query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
const collaborateur = collaborateurResult.recordset;
if (collaborateur.length === 0) {
await transaction.rollback();
return res.status(404).json({
success: false,
message: 'Collaborateur non trouvé'
});
}
const collab = collaborateur[0];
const currentYear = new Date().getFullYear();
// Récupérer l'ID du type "Récupération"
const recupTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
const recupType = recupTypeResult.recordset;
if (recupType.length === 0) {
await transaction.rollback();
return res.status(500).json({
success: false,
message: 'Type de congé "Récupération" non trouvé'
});
}
const recupTypeId = recupType[0].Id;
// Vérifier le solde actuel
const compteurResult = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @recupTypeId
AND Annee = @currentYear
`);
const compteur = compteurResult.recordset;
if (compteur.length === 0 || compteur[0].Solde < nombreJours) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Solde de récupération insuffisant',
soldeActuel: compteur.length > 0 ? compteur[0].Solde : 0
});
}
const nouveauTotal = Math.max(0, parseFloat(compteur[0].Total) - nombreJours);
const nouveauSolde = Math.max(0, parseFloat(compteur[0].Solde) - nombreJours);
// Retirer les jours
await transaction.request()
.input('total', sql.Decimal(10, 2), nouveauTotal)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.input('id', sql.Int, compteur[0].Id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
console.log(`${nombreJours}j retirés du compteur Récupération`);
// Enregistrer dans l'historique
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar, `Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (@userId, 'Retrait récupération', @details, GETDATE())
`);
await transaction.commit();
// Webhook
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: collaborateurId,
typeCongeId: recupTypeId,
typeConge: 'Récupération',
typeUpdate: 'retrait_manuel_rh',
annee: currentYear,
nouveauTotal: nouveauTotal,
nouveauSolde: nouveauSolde,
source: 'bouton_moins_rh',
timestamp: new Date().toISOString()
}
);
console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)');
} catch (webhookError) {
console.error('❌ Erreur webhook (non bloquant):', webhookError.message);
}
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeConge: 'Récupération',
action: 'retrait',
nombreJours: nombreJours,
timestamp: new Date().toISOString()
}, collaborateurId);
res.json({
success: true,
message: 'Jours de récupération retirés avec succès',
collaborateur: `${collab.prenom} ${collab.nom}`,
nombreJours: nombreJours,
annee: currentYear
});
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur retrait récupération:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
app.get('/equipes', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT
s.Id,
s.Nom as nomService,
s.Nom as service,
COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) as nombreMembres,
COUNT(DISTINCT CASE WHEN dc.Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN dc.Id END) as demandesEnAttente,
COALESCE(
STUFF((
SELECT DISTINCT ',' + c1.Nom
FROM ServiceAffectation sa1
JOIN Campus c1 ON sa1.CampusId = c1.Id
WHERE sa1.ServiceId = s.Id
ORDER BY ',' + c1.Nom
FOR XML PATH('')
), 1, 1, ''),
STUFF((
SELECT DISTINCT ',' + c2.Nom
FROM CollaborateurAD ca2
JOIN Campus c2 ON ca2.CampusId = c2.Id
WHERE ca2.ServiceId = s.Id AND (ca2.Actif = 1 OR ca2.Actif IS NULL)
ORDER BY ',' + c2.Nom
FOR XML PATH('')
), 1, 1, ''),
'Non assigné'
) as campus
FROM Services s
LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId AND (ca.Actif = 1 OR ca.Actif IS NULL)
LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId AND dc.Statut = 'En attente'
GROUP BY s.Id, s.Nom
HAVING COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) > 0
OR EXISTS (SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id)
ORDER BY s.Nom
`);
const equipes = result.recordset;
res.json(equipes);
} catch (error) {
console.error('❌ Erreur /equipes:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/equipes/:id', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.params.id)
.query(`
SELECT
s.Id,
s.Nom,
s.Nom as nomService,
COUNT(DISTINCT CASE
WHEN (ca.Actif = 1 OR ca.Actif IS NULL)
THEN ca.id
END) as nombreMembres,
COUNT(DISTINCT CASE
WHEN dc.Statut = 'En attente'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
THEN dc.Id
END) as demandesEnAttente,
COALESCE(
STUFF((
SELECT DISTINCT ', ' + c.Nom
FROM ServiceAffectation sa
JOIN Campus c ON sa.CampusId = c.Id
WHERE sa.ServiceId = s.Id
ORDER BY ', ' + c.Nom
FOR XML PATH('')
), 1, 2, ''),
'Non assigné'
) as campus
FROM Services s
LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId
LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId
WHERE s.Id = @id
GROUP BY s.Id, s.Nom
`);
const equipes = result.recordset;
if (equipes.length === 0) {
return res.status(404).json({ error: 'Service non trouvé' });
}
res.json(equipes[0]);
} catch (error) {
console.error('Erreur /equipes/:id:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/equipes/:id/membres', authenticateToken, async (req, res) => {
try {
const result = await pool.request()
.input('id', sql.Int, req.params.id)
.query(`
SELECT
ca.id,
CONCAT(ca.prenom, ' ', ca.nom) as nom,
ca.email,
ca.role,
ca.Actif,
ca.DateSortie,
ca.TypeContrat,
(SELECT MAX(Solde) FROM CompteurConges cc
WHERE cc.CollaborateurADId = ca.id
AND cc.TypeCongeId = 1
AND cc.Annee = YEAR(GETDATE())) as soldeCP,
(SELECT MAX(Solde) FROM CompteurConges cc2
WHERE cc2.CollaborateurADId = ca.id
AND cc2.TypeCongeId = 2
AND cc2.Annee = YEAR(GETDATE())) as soldeRTT
FROM CollaborateurAD ca
WHERE ca.ServiceId = @id
AND (ca.Actif = 1 OR ca.Actif IS NULL)
ORDER BY ca.nom, ca.prenom
`);
const membres = result.recordset;
console.log(`${membres.length} membres trouvés pour le service ${req.params.id}`);
res.json(membres);
} catch (error) {
console.error('Erreur /equipes/:id/membres:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/stats', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT
COUNT(CASE WHEN Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as enAttente,
COUNT(CASE WHEN Statut = 'Validée' AND MONTH(DateValidation) = MONTH(GETDATE()) AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as valideeCeMois,
COUNT(DISTINCT ca.ServiceId) as nombreEquipes
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
WHERE (ca.Actif = 1 OR ca.Actif IS NULL)
`);
const stats = result.recordset;
res.json(stats[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Route /historique modifiée pour inclure le collaborateur concerné ET la personne qui a réalisé l'action
app.get('/historique', authenticateToken, async (req, res) => {
try {
const { dateDebut, dateFin, action } = req.query;
const request = pool.request();
let whereClause = 'WHERE 1=1';
if (dateDebut) {
whereClause += ' AND ha.DateAction >= @dateDebut';
request.input('dateDebut', sql.DateTime, dateDebut);
}
if (dateFin) {
whereClause += ' AND ha.DateAction <= @dateFin';
request.input('dateFin', sql.DateTime, dateFin);
}
if (action && action !== 'all') {
whereClause += ' AND ha.Action = @action';
request.input('action', sql.VarChar, action);
}
// ✅ REQUÊTE MODIFIÉE : Inclut le collaborateur concerné ET la personne qui a réalisé l'action
const query = `
SELECT TOP 500
ha.Id,
-- Personne qui a réalisé l'action (celui qui est dans HistoriqueActions.CollaborateurADId)
CONCAT(ca.prenom, ' ', ca.nom) AS realisePar,
ca.email AS realiseParEmail,
-- Collaborateur concerné par l'action (récupéré via la demande de congé)
CASE
WHEN dc.CollaborateurADId IS NOT NULL
THEN CONCAT(collab_concerne.prenom, ' ', collab_concerne.nom)
ELSE CONCAT(ca.prenom, ' ', ca.nom)
END AS collaborateur,
CASE
WHEN dc.CollaborateurADId IS NOT NULL
THEN collab_concerne.email
ELSE ca.email
END AS collaborateurEmail,
ha.Action,
ha.Details,
ha.DateAction,
ha.AdresseIP,
ha.DemandeCongeId
FROM HistoriqueActions ha
-- Jointure pour récupérer la personne qui a réalisé l'action
JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id
-- Jointure optionnelle pour récupérer la demande de congé associée
LEFT JOIN DemandeConge dc ON ha.DemandeCongeId = dc.Id
-- Jointure optionnelle pour récupérer le collaborateur concerné par la demande
LEFT JOIN CollaborateurAD collab_concerne ON dc.CollaborateurADId = collab_concerne.id
${whereClause}
ORDER BY ha.DateAction DESC
`;
console.log('📊 Requête historique SQL:', query);
const result = await request.query(query);
const historique = result.recordset || [];
console.log(`${historique.length} entrées d'historique trouvées`);
// ⭐ TOUJOURS retourner un tableau
res.json(historique);
} catch (error) {
console.error('❌ Erreur /historique:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// FONCTIONS DE CALCUL CP ET RTT
// ================================================
/**
* Calcule l'acquisition CP avec la formule Excel exacte
*/
function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
const mois = d.getMonth() + 1; // 1-12
// 1. Déterminer le début de l'exercice CP (01/06)
let exerciceDebut;
if (mois >= 6) {
exerciceDebut = new Date(annee, 5, 1); // 01/06/N
} else {
exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
}
exerciceDebut.setHours(0, 0, 0, 0);
// 2. Ajuster si date d'entrée postérieure
let dateDebutAcquis = new Date(exerciceDebut);
if (dateEntree && dateEntree !== null) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (!isNaN(entree.getTime()) && entree > exerciceDebut) {
dateDebutAcquis = entree;
}
}
// 3. Calculer avec la formule Excel
const coeffCP = 25 / 12; // 2.0833
const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
// 4. Plafonner à 25 jours
return Math.min(acquisition, 25);
}
/**
* Calcule l'acquisition RTT avec la configuration variable
*/
async function calculerAcquisitionRTT(collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1. Récupérer les infos du collaborateur
const collabInfoResult = await pool.request()
.input('collaborateurId', sql.Int, collaborateurId)
.query('SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = @collaborateurId');
const collabInfo = collabInfoResult.recordset;
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2. Apprentis : pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat
};
}
// 3. Récupérer la configuration RTT
const config = await getConfigurationRTT(annee, typeContrat);
// 4. Début d'acquisition : 01/01/N ou date d'entrée
let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N
dateDebutAcquis.setHours(0, 0, 0, 0);
if (dateEntree && dateEntree !== null) {
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
if (!isNaN(entree.getTime())) {
if (entree.getFullYear() === annee && entree > dateDebutAcquis) {
dateDebutAcquis = entree;
}
if (entree.getFullYear() > annee) {
return {
acquisition: 0,
moisTravailles: 0,
config: config,
typeContrat: typeContrat
};
}
}
}
// 5. Calculer avec la formule Excel
const acquisition = calculerAcquisitionFormuleExcel(
dateDebutAcquis,
d,
config.acquisitionMensuelle
);
// 6. Calculer les mois travaillés
const moisTravailles = config.acquisitionMensuelle > 0
? acquisition / config.acquisitionMensuelle
: 0;
// 7. Plafonner au maximum annuel
const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
return {
acquisition: Math.round(acquisitionFinale * 100) / 100,
moisTravailles: Math.round(moisTravailles * 100) / 100,
config: config,
typeContrat: typeContrat
};
}
function dateDifMonths(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
// Vérifier que les dates sont valides
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) {
console.error('❌ Date invalide dans dateDifMonths');
return 0;
}
let months = (d2.getFullYear() - d1.getFullYear()) * 12;
months += d2.getMonth() - d1.getMonth();
// Si le jour de d2 < jour de d1, on n'a pas encore complété le mois
if (d2.getDate() < d1.getDate()) {
months--;
}
return Math.max(0, months);
}
/**
* Formule Excel exacte pour calculer l'acquisition
*/
function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) {
const b1 = new Date(dateDebut);
const b2 = new Date(dateReference);
b1.setHours(0, 0, 0, 0);
b2.setHours(0, 0, 0, 0);
// Vérifier que les dates sont valides
if (isNaN(b1.getTime()) || isNaN(b2.getTime())) {
console.error('❌ Date invalide dans calculerAcquisitionFormuleExcel');
return 0;
}
// Vérifier que coeffMensuel est un nombre
if (isNaN(coeffMensuel) || coeffMensuel <= 0) {
console.error('❌ Coefficient mensuel invalide:', coeffMensuel);
return 0;
}
// Si date référence avant date début
if (b2 < b1) return 0;
// Si même mois et même année
if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) {
const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const joursAcquis = b2.getDate() - b1.getDate() + 1;
return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100;
}
// 1. Fraction du PREMIER mois
const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate();
const jourB1 = b1.getDate();
const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1;
// 2. Mois COMPLETS entre
const moisComplets = dateDifMonths(b1, b2) - 1;
// 3. Fraction du DERNIER mois
const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
const jourB2 = b2.getDate();
const fractionDernierMois = jourB2 / joursFinMoisB2;
// 4. Total
const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois;
const acquisition = totalMois * coeffMensuel;
return Math.round(acquisition * 100) / 100;
}
/**
* Récupère la configuration RTT pour une année et un type de contrat
*/
async function getConfigurationRTT(annee, typeContrat = '37h') {
try {
const result = await pool.request()
.input('annee', sql.Int, annee)
.input('typeContrat', sql.VarChar, typeContrat)
.query(`
SELECT JoursAnnuels, AcquisitionMensuelle
FROM ConfigurationRTT
WHERE Annee = @annee AND TypeContrat = @typeContrat
`);
const config = result.recordset;
if (config.length > 0) {
return {
joursAnnuels: parseFloat(config[0].JoursAnnuels),
acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle)
};
}
// Sinon, utiliser les règles par défaut
return getConfigurationRTTDefaut(annee, typeContrat);
} catch (error) {
console.error('Erreur getConfigurationRTT:', error);
return getConfigurationRTTDefaut(annee, typeContrat);
}
}
/**
* Configuration RTT par défaut selon les règles métier
*/
function getConfigurationRTTDefaut(annee, typeContrat = '37h') {
// 37h : toujours 10 RTT/an
if (typeContrat === '37h' || typeContrat === 'tempspartiel') {
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
}
// Forfait jour : dépend de l'année
if (typeContrat === 'forfaitjour') {
if (annee <= 2025) {
// 2025 et avant : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12 // 0.8333
};
} else {
// 2026 et après : 12 RTT/an
return {
joursAnnuels: 12,
acquisitionMensuelle: 12 / 12 // 1.0
};
}
}
// Par défaut : 10 RTT/an
return {
joursAnnuels: 10,
acquisitionMensuelle: 10 / 12
};
}
// Suite et fin - Réinitialisation compteurs, routes API V2, etc.
// ========================================
// ROUTE COMPLÈTE : /reinitializeAllCounters
// ========================================
app.post('/reinitializeAllCounters', async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
console.log('🔄 Réinitialisation de tous les compteurs...');
const collaborateursResult = await transaction.request().query(`
SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role
FROM CollaborateurAD
WHERE Actif = 1 OR Actif IS NULL
`);
const collaborateurs = collaborateursResult.recordset;
console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
const dateRefParam = req.body.dateReference;
const today = dateRefParam ? new Date(dateRefParam) : new Date();
const currentYear = today.getFullYear();
const previousYear = currentYear - 1;
const results = [];
// Récupérer les types de congés
const cpTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'Congé payé'");
const cpType = cpTypeResult.recordset;
const rttTypeResult = await transaction.request()
.query("SELECT Id FROM TypeConge WHERE Nom = 'RTT'");
const rttType = rttTypeResult.recordset;
for (const collab of collaborateurs) {
console.log(`\n${'='.repeat(60)}`);
console.log(`👤 ${collab.prenom} ${collab.nom} (ID: ${collab.id})`);
console.log(` Type contrat: ${collab.TypeContrat || '37h'}`);
console.log(` Date entrée: ${collab.DateEntree ? new Date(collab.DateEntree).toISOString().split('T')[0] : 'NULL'}`);
console.log(` Rôle: ${collab.role || 'Non défini'}`);
const dateEntree = collab.DateEntree;
const typeContrat = collab.TypeContrat || '37h';
const isApprenti = collab.role === 'Apprenti';
// ==========================================
// CALCUL CP
// ==========================================
let acquisCP = calculerAcquisitionCP(today, dateEntree);
if (isNaN(acquisCP) || !isFinite(acquisCP)) {
console.warn(`⚠️ Acquisition CP invalide - Forcé à 0`);
acquisCP = 0;
}
// ==========================================
// CALCUL RTT
// ==========================================
let acquisRTT = 0;
if (!isApprenti) {
try {
const rttData = await calculerAcquisitionRTT(collab.id, today);
acquisRTT = rttData.acquisition;
if (isNaN(acquisRTT) || !isFinite(acquisRTT)) {
console.warn(`⚠️ Acquisition RTT invalide - Forcé à 0`);
acquisRTT = 0;
}
} catch (error) {
console.error(`❌ Erreur calcul RTT:`, error.message);
acquisRTT = 0;
}
}
// ==========================================
// MISE À JOUR CP N
// ==========================================
if (cpType.length > 0) {
const deductionsCPResult = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @collabId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @currentYear
AND dc.Statut != 'Refusé'
`);
let totalConsomme = parseFloat(deductionsCPResult.recordset[0].totalConsomme || 0);
if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`);
totalConsomme = 0;
}
const compteurExistingResult = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @currentYear
`);
const compteurExisting = compteurExistingResult.recordset;
let soldeReporte = compteurExisting.length > 0
? parseFloat(compteurExisting[0].SoldeReporte || 0)
: 0;
if (isNaN(soldeReporte) || !isFinite(soldeReporte)) {
console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`);
soldeReporte = 0;
}
let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme);
if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) {
console.warn(`⚠️ Nouveau solde CP invalide - Forcé à 0`);
nouveauSolde = 0;
}
console.log(` 📊 CP - Acquis: ${acquisCP.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Reporté: ${soldeReporte.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`);
if (compteurExisting.length > 0) {
await transaction.request()
.input('total', sql.Decimal(10, 2), acquisCP)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('currentYear', sql.Int, currentYear)
.query(`
UPDATE CompteurConges
SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE()
WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear
`);
} else {
await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('currentYear', sql.Int, currentYear)
.input('total', sql.Decimal(10, 2), acquisCP)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE())
`);
}
// Créer CP N-1 si nécessaire
const cpN1Result = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('previousYear', sql.Int, previousYear)
.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear
`);
const cpN1 = cpN1Result.recordset;
if (cpN1.length === 0) {
await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, cpType[0].Id)
.input('previousYear', sql.Int, previousYear)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE())
`);
}
}
// ==========================================
// MISE À JOUR RTT N
// ==========================================
if (rttType.length > 0 && !isApprenti) {
const deductionsRTTResult = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, rttType[0].Id)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @collabId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @currentYear
AND dc.Statut != 'Refusé'
`);
let totalConsomme = parseFloat(deductionsRTTResult.recordset[0].totalConsomme || 0);
if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`);
totalConsomme = 0;
}
let nouveauSolde = Math.max(0, acquisRTT - totalConsomme);
if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) {
console.warn(`⚠️ Nouveau solde RTT invalide - Forcé à 0`);
nouveauSolde = 0;
}
console.log(` 📊 RTT - Acquis: ${acquisRTT.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`);
const compteurExistingResult = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, rttType[0].Id)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear
`);
const compteurExisting = compteurExistingResult.recordset;
if (compteurExisting.length > 0) {
await transaction.request()
.input('total', sql.Decimal(10, 2), acquisRTT)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.input('id', sql.Int, compteurExisting[0].Id)
.query(`
UPDATE CompteurConges
SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE()
WHERE Id = @id
`);
} else {
await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, rttType[0].Id)
.input('currentYear', sql.Int, currentYear)
.input('total', sql.Decimal(10, 2), acquisRTT)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE())
`);
}
// Créer RTT N-1 si nécessaire
const rttN1Result = await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, rttType[0].Id)
.input('previousYear', sql.Int, previousYear)
.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear
`);
const rttN1 = rttN1Result.recordset;
if (rttN1.length === 0) {
await transaction.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, rttType[0].Id)
.input('previousYear', sql.Int, previousYear)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE())
`);
}
}
// Ajouter au résultat
results.push({
collaborateur: `${collab.prenom} ${collab.nom}`,
typecontrat: typeContrat,
cpacquis: acquisCP.toFixed(2),
rttacquis: acquisRTT.toFixed(2)
});
}
await transaction.commit();
console.log('\n✅ Réinitialisation terminée');
res.json({
success: true,
message: `Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`,
datereference: today.toISOString().split('T')[0],
totalcollaborateurs: collaborateurs.length,
results: results
});
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur réinitialisation:', error);
res.status(500).json({
success: false,
message: 'Erreur lors de la réinitialisation',
error: error.message
});
}
});
// ============================================================
// ENDPOINTS API V2
// ============================================================
app.get('/v2/compteurs', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT
cc.Id AS id,
cc.CollaborateurADId AS collaborateurId,
cc.TypeCongeId AS typeCongeId,
CONCAT(c.nom, ' ', c.prenom) AS employe,
c.email AS email,
c.service AS service,
tc.Nom AS typeConge,
cc.Annee AS annee,
cc.Total AS total,
cc.Solde AS solde,
cc.SoldeReporte AS soldeReporte,
(cc.Total - cc.Solde) AS consomme,
c.TypeContrat AS typeContrat,
c.role AS role
FROM CompteurConges cc
INNER JOIN CollaborateurAD c ON cc.CollaborateurADId = c.id
INNER JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE (c.description IS NULL OR c.description NOT LIKE '%stagiaire%')
ORDER BY c.nom ASC, cc.Annee DESC
`);
return res.json(result.recordset);
} catch (error) {
console.error("❌ Erreur GET compteurs V2 :", error);
return res.status(500).json({ message: "Erreur serveur V2" });
}
});
app.put('/v2/compteurs/update', async (req, res) => {
try {
const {
collaborateurId,
typeConge,
annee,
nouveauTotal,
nouveauSolde,
source
} = req.body;
if (!collaborateurId || !typeConge || !annee) {
return res.status(400).json({ message: "Champs manquants" });
}
console.log('📝 Modification compteur V2:', { collaborateurId, typeConge, annee, nouveauTotal, nouveauSolde });
// Mapper le nom exact du type de congé
let nomTypeConge;
switch (typeConge) {
case 'CP':
nomTypeConge = 'Congé payé';
break;
case 'RTT':
nomTypeConge = 'RTT';
break;
case 'Récupération':
case 'Recuperation':
case 'recup':
nomTypeConge = 'Récupération';
break;
default:
nomTypeConge = typeConge;
}
console.log(` Recherche du type: "${nomTypeConge}"`);
// Trouver l'ID du type de congé
const typeRowResult = await pool.request()
.input('nomTypeConge', sql.NVarChar, nomTypeConge)
.query("SELECT Id FROM TypeConge WHERE Nom = @nomTypeConge");
const typeRow = typeRowResult.recordset;
if (typeRow.length === 0) {
console.error(`❌ Type de congé "${nomTypeConge}" introuvable`);
return res.status(404).json({
message: `Type de congé "${nomTypeConge}" introuvable`,
nomRecherche: nomTypeConge
});
}
const typeCongeId = typeRow[0].Id;
console.log(` ✅ Type trouvé - ID: ${typeCongeId}`);
// Trouver compteur
const compResult = await pool.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('typeCongeId', sql.Int, typeCongeId)
.input('annee', sql.Int, annee)
.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @typeCongeId
AND Annee = @annee
`);
const comp = compResult.recordset;
if (comp.length === 0) {
console.error(`❌ Compteur introuvable pour collaborateur ${collaborateurId}, type ${typeCongeId}, année ${annee}`);
return res.status(404).json({
message: "Compteur introuvable",
details: { collaborateurId, typeCongeId, annee }
});
}
// Mise à jour compteur
await pool.request()
.input('total', sql.Decimal(10, 2), nouveauTotal)
.input('solde', sql.Decimal(10, 2), nouveauSolde)
.input('id', sql.Int, comp[0].Id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
console.log(` ✅ Compteur mis à jour - Total: ${nouveauTotal}, Solde: ${nouveauSolde}`);
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeConge: nomTypeConge,
annee: annee,
nouveauTotal: nouveauTotal,
nouveauSolde: nouveauSolde,
source: source || 'RH_UI',
timestamp: new Date().toISOString()
}, collaborateurId);
// Envoyer webhook
try {
if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: collaborateurId,
typeConge: nomTypeConge,
typeUpdate: 'modification_manuelle_rh',
annee: annee,
nouveauTotal: nouveauTotal,
nouveauSolde: nouveauSolde,
source: source || 'RH_UI',
timestamp: new Date().toISOString()
}
);
console.log(' ✅ Webhook envoyé au serveur collaborateurs');
}
} catch (webhookError) {
console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
}
res.json({
success: true,
message: "Compteur mis à jour (V2)",
details: {
typeConge: nomTypeConge,
typeCongeId: typeCongeId,
nouveauTotal: nouveauTotal,
nouveauSolde: nouveauSolde
}
});
} catch (err) {
console.error("❌ Erreur update compteur V2:", err);
res.status(500).json({
message: "Erreur serveur",
error: err.message
});
}
});
// ================================================
// ROUTE POUR SAISIR UN JPOSF
// ================================================
app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
console.log('\n📥 ========== DÉBUT SAISIE JPOSF ==========');
console.log('📥 Body reçu:', JSON.stringify(req.body, null, 2));
console.log('📥 User:', req.user);
await transaction.begin();
console.log('✅ Transaction démarrée');
// Vérifier les permissions
if (!['Admin', 'RH'].includes(req.user.role)) {
console.log('❌ Accès refusé - Role:', req.user.role);
await transaction.rollback();
return res.status(403).json({
success: false,
error: 'Accès non autorisé',
message: 'Seuls les Admin et RH peuvent saisir des JPOSF'
});
}
const { collaborateur_id, date, periode_journee, commentaire } = req.body;
// Validation des données
if (!collaborateur_id) {
console.log('❌ collaborateur_id manquant');
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'ID du collaborateur manquant'
});
}
if (!date) {
console.log('❌ date manquante');
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Date manquante'
});
}
if (!periode_journee) {
console.log('❌ periode_journee manquante');
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Période de journée manquante'
});
}
console.log('📋 Données validées:', { collaborateur_id, date, periode_journee });
// ========== ÉTAPE 1: Récupérer le collaborateur ==========
console.log('\n🔍 ÉTAPE 1: Récupération du collaborateur...');
const collabResult = await transaction.request()
.input('id', sql.Int, parseInt(collaborateur_id))
.query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
console.log(' Résultat:', collabResult.recordset.length, 'enregistrement(s)');
if (collabResult.recordset.length === 0) {
console.log('❌ Collaborateur non trouvé');
await transaction.rollback();
return res.status(404).json({
success: false,
message: `Collaborateur ID ${collaborateur_id} non trouvé`
});
}
const collab = collabResult.recordset[0];
console.log(' ✅ Collaborateur trouvé:', collab.prenom, collab.nom);
if (collab.Actif === false || collab.Actif === 0) {
console.log('❌ Collaborateur inactif');
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'Impossible d\'ajouter des jours à un compte désactivé'
});
}
const currentYear = new Date().getFullYear();
const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5;
console.log(' Année:', currentYear);
console.log(' Nombre de jours:', nombreJours);
// ========== ÉTAPE 2: Récupérer les types de congé ==========
console.log('\n🔍 ÉTAPE 2: Récupération des types de congé...');
const jposfTypeResult = await transaction.request()
.query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'JPOSF'");
console.log(' JPOSF trouvé:', jposfTypeResult.recordset.length > 0);
if (jposfTypeResult.recordset.length === 0) {
console.log('❌ Type JPOSF non trouvé dans TypeConge');
// Lister les types disponibles pour debug
const allTypesResult = await transaction.request()
.query("SELECT Id, Nom FROM TypeConge WHERE Actif = 1 OR Actif IS NULL");
console.log(' Types disponibles:', allTypesResult.recordset.map(t => t.Nom).join(', '));
await transaction.rollback();
return res.status(500).json({
success: false,
message: 'Type de congé "JPOSF" non trouvé dans la base de données',
details: 'Contactez l\'administrateur pour créer ce type de congé'
});
}
const recupTypeResult = await transaction.request()
.query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'Récupération'");
console.log(' Récupération trouvé:', recupTypeResult.recordset.length > 0);
if (recupTypeResult.recordset.length === 0) {
console.log('❌ Type Récupération non trouvé dans TypeConge');
await transaction.rollback();
return res.status(500).json({
success: false,
message: 'Type de congé "Récupération" non trouvé dans la base de données',
details: 'Contactez l\'administrateur pour créer ce type de congé'
});
}
const jposfTypeId = jposfTypeResult.recordset[0].Id;
const recupTypeId = recupTypeResult.recordset[0].Id;
console.log(' ✅ JPOSF TypeId:', jposfTypeId);
console.log(' ✅ Récupération TypeId:', recupTypeId);
// ========== ÉTAPE 3: Mettre à jour le compteur Récupération ==========
console.log('\n🔍 ÉTAPE 3: Mise à jour du compteur Récupération...');
const compteurRecupResult = await transaction.request()
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.query(`
SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = @collaborateurId
AND TypeCongeId = @recupTypeId
AND Annee = @currentYear
`);
let nouveauTotalRecup, nouveauSoldeRecup;
if (compteurRecupResult.recordset.length > 0) {
const compteur = compteurRecupResult.recordset[0];
nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours;
nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours;
console.log(' Compteur existant - Ancien Total:', compteur.Total, '-> Nouveau:', nouveauTotalRecup);
console.log(' Compteur existant - Ancien Solde:', compteur.Solde, '-> Nouveau:', nouveauSoldeRecup);
await transaction.request()
.input('total', sql.Decimal(10, 2), nouveauTotalRecup)
.input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
.input('id', sql.Int, compteur.Id)
.query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
console.log(' ✅ Compteur Récupération mis à jour');
} else {
nouveauTotalRecup = nombreJours;
nouveauSoldeRecup = nombreJours;
console.log(' Création nouveau compteur - Total:', nouveauTotalRecup, 'Solde:', nouveauSoldeRecup);
await transaction.request()
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('recupTypeId', sql.Int, recupTypeId)
.input('currentYear', sql.Int, currentYear)
.input('total', sql.Decimal(10, 2), nouveauTotalRecup)
.input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
`);
console.log(' ✅ Compteur Récupération créé');
}
// ========== ÉTAPE 4: Créer la demande JPOSF ==========
console.log('\n🔍 ÉTAPE 4: Création de la demande JPOSF...');
// Préparer le commentaire
const commentaireFinal = commentaire && commentaire.trim()
? commentaire.trim()
: `JPOSF - ${periode_journee}`;
console.log(' Commentaire:', commentaireFinal);
console.log(' Date:', date);
console.log(' ValidateurId:', req.user.id);
// ⭐ MÉTHODE ROBUSTE: INSERT puis SELECT séparé
try {
// D'abord, faire l'INSERT sans OUTPUT
await transaction.request()
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('dateDebut', sql.Date, date)
.input('dateFin', sql.Date, date)
.input('commentaire', sql.NVarChar(500), commentaireFinal)
.input('totalJours', sql.Decimal(10, 2), nombreJours)
.input('validateurId', sql.Int, req.user.id)
.query(`
INSERT INTO DemandeConge
(CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
VALUES (@collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
`);
console.log(' ✅ INSERT DemandeConge réussi');
// Ensuite, récupérer l'ID avec SCOPE_IDENTITY ou en cherchant le dernier enregistrement
const lastIdResult = await transaction.request()
.query('SELECT SCOPE_IDENTITY() AS Id');
let demandeId = lastIdResult.recordset[0]?.Id;
// Si SCOPE_IDENTITY ne fonctionne pas, fallback
if (!demandeId) {
console.log(' ⚠️ SCOPE_IDENTITY() a retourné NULL, utilisation du fallback...');
const fallbackResult = await transaction.request()
.input('collaborateurId', sql.Int, parseInt(collaborateur_id))
.input('dateDebut', sql.Date, date)
.query(`
SELECT TOP 1 Id FROM DemandeConge
WHERE CollaborateurADId = @collaborateurId
AND DateDebut = @dateDebut
AND Statut = 'Validée'
ORDER BY DateDemande DESC, Id DESC
`);
demandeId = fallbackResult.recordset[0]?.Id;
}
console.log(' DemandeId récupéré:', demandeId);
if (!demandeId) {
throw new Error('Impossible de récupérer l\'ID de la demande créée');
}
// ========== ÉTAPE 5: Lier avec le type JPOSF ==========
console.log('\n🔍 ÉTAPE 5: Liaison avec le type JPOSF...');
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('typeId', sql.Int, jposfTypeId)
.input('nombreJours', sql.Decimal(10, 2), nombreJours)
.input('periode', sql.NVarChar(50), periode_journee) // ⭐ AJOUTÉ
.query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeId, @nombreJours, @periode)');
console.log(' ✅ DemandeCongeType créé');
// ========== ÉTAPE 6: Historique ==========
console.log('\n🔍 ÉTAPE 6: Enregistrement dans l\'historique...');
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
.input('demandeId', sql.Int, demandeId)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
`);
console.log(' ✅ Historique enregistré');
// ========== COMMIT ==========
await transaction.commit();
console.log('\n✅ ========== TRANSACTION COMMIT ==========');
// ========== Notifications SSE ==========
try {
notifyClients({
type: 'demande-validated',
demandeId: demandeId,
statut: 'Validée',
typeConge: 'JPOSF',
couleurHex: '#d946ef',
collaborateurId: parseInt(collaborateur_id),
timestamp: new Date().toISOString()
}, parseInt(collaborateur_id));
notifyClients({
type: 'compteur-updated',
collaborateurId: parseInt(collaborateur_id),
typeConge: 'Récupération',
action: 'saisie_jposf_rh',
nouveauSolde: nouveauSoldeRecup,
timestamp: new Date().toISOString()
}, parseInt(collaborateur_id));
console.log('✅ Notifications SSE envoyées');
} catch (sseError) {
console.error('⚠️ Erreur SSE (non bloquant):', sseError.message);
}
// ========== Webhooks ==========
try {
if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_VALIDATED,
{
demandeId: demandeId,
statut: 'Validée',
typeConge: 'JPOSF',
collaborateurId: parseInt(collaborateur_id),
validateurId: req.user.id
}
);
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: parseInt(collaborateur_id),
typeConge: 'Récupération',
typeUpdate: 'saisie_jposf'
}
);
console.log('✅ Webhooks envoyés');
}
} catch (webhookError) {
console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
}
// ========== Email ==========
try {
const accessToken = await getGraphToken();
if (accessToken && collab.email) {
const fromEmail = 'gtanoreply@ensup.eu';
const subject = '📅 JPOSF enregistré';
const body = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #8b5cf6; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">📅 JPOSF enregistré</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p>Bonjour <strong>${collab.prenom} ${collab.nom}</strong>,</p>
<p>Une Journée Portes Ouvertes a été enregistrée pour vous :</p>
<div style="background-color: white; border-left: 4px solid #8b5cf6; padding: 15px; margin: 20px 0;">
<p><strong>Date :</strong> ${new Date(date).toLocaleDateString('fr-FR')}</p>
<p><strong>Période :</strong> ${periode_journee}</p>
<p><strong>Jours ajoutés :</strong> ${nombreJours}j</p>
${commentaire ? `<p><strong>Commentaire :</strong> ${commentaire}</p>` : ''}
</div>
<p>✅ <strong>+${nombreJours}j</strong> ont été ajoutés à votre compteur de récupération</p>
</div>
</div>
`;
await sendMailGraph(accessToken, fromEmail, collab.email, subject, body);
console.log('✅ Email envoyé à', collab.email);
}
} catch (emailError) {
console.error('⚠️ Erreur email (non bloquant):', emailError.message);
}
// ========== Réponse succès ==========
console.log('\n✅ ========== JPOSF ENREGISTRÉ AVEC SUCCÈS ==========\n');
return res.json({
success: true,
message: 'JPOSF enregistré avec succès',
collaborateur: `${collab.prenom} ${collab.nom}`,
date: date,
periode: periode_journee,
jours_ajoutes: nombreJours,
nouveau_solde_recup: nouveauSoldeRecup,
demandeId: demandeId
});
} catch (insertError) {
console.error('\n❌ ERREUR lors de l\'INSERT DemandeConge:', insertError.message);
console.error(' Stack:', insertError.stack);
throw insertError;
}
} catch (error) {
console.error('\n❌ ========== ERREUR SAISIE JPOSF ==========');
console.error('Message:', error.message);
console.error('Stack:', error.stack);
try {
await transaction.rollback();
console.log('✅ Transaction rollback effectué');
} catch (rollbackError) {
console.error('⚠️ Erreur rollback:', rollbackError.message);
}
return res.status(500).json({
success: false,
message: 'Erreur serveur lors de la saisie du JPOSF',
error: error.message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
// ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters
app.get('/getAllDetailedCounters', async (req, res) => {
try {
console.log('📊 Récupération de TOUS les compteurs détaillés');
const today = new Date();
const currentYearSys = today.getFullYear(); // 2026
// ── Détection bascule CP ──────────────────────────────────────────
const cpTypeForCheck = await pool.request()
.query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'");
let currentYear = currentYearSys;
let previousYear = currentYearSys - 1;
if (cpTypeForCheck.recordset.length > 0) {
const cpTypeId = cpTypeForCheck.recordset[0].Id;
const checkBascule = await pool.request()
.input('cpTypeId', sql.Int, cpTypeId)
.input('anneeN1', sql.Int, currentYearSys + 1)
.query(`
SELECT TOP 1 Id FROM CompteurConges
WHERE TypeCongeId = @cpTypeId
AND Annee = @anneeN1
`);
if (checkBascule.recordset.length > 0) {
currentYear = currentYearSys + 1; // 2027
previousYear = currentYearSys; // 2026
console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
}
}
const anneeRTT = currentYearSys;
const anneeRecup = currentYearSys;
console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
const typesCongeResult = await pool.request().query(`
SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Récupération')
`);
const typeCongeMap = {};
typesCongeResult.recordset.forEach(tc => {
typeCongeMap[tc.Nom] = tc.Id;
});
const collaborateursResult = await pool.request().query(`
SELECT
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
COALESCE(s.Nom, 'Non assigné') as service,
s.Id as serviceId,
ca.CampusId as campusId,
COALESCE(camp.Nom, 'Sans campus') as campusNom,
ca.SocieteId as societeId,
COALESCE(soc.Nom, 'Sans société') as societe
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus camp ON ca.CampusId = camp.Id
LEFT JOIN Societe soc ON ca.SocieteId = soc.Id
WHERE (ca.Actif = 1 OR ca.Actif IS NULL)
AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%')
ORDER BY soc.Nom, camp.Nom, s.Nom, ca.nom, ca.prenom
`);
const collaborateurs = collaborateursResult.recordset;
const resultats = [];
console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
for (const collab of collaborateurs) {
const campusNom = collab.campusNom;
// ==================== CP N ====================
if (typeCongeMap['Congé payé']) {
const cpNResult = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['Congé payé'])
.input('annee', sql.Int, currentYear)
.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const cpN = cpNResult.recordset;
let cpTotalN, cpSoldeN;
if (cpN.length > 0) {
cpTotalN = parseFloat(cpN[0].Total);
const soldeReporte = parseFloat(cpN[0].SoldeReporte || 0);
// Solde réel = Solde - SoldeReporte
cpSoldeN = Math.max(0, parseFloat(cpN[0].Solde) - soldeReporte);
} else {
if (currentYear > currentYearSys) {
// Bascule faite mais pas de compteur 2027 pour ce collab → 0
cpTotalN = 0;
cpSoldeN = 0;
} else {
cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree);
const consommeResult = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['Congé payé'])
.input('annee', sql.Int, currentYear)
.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = @collabId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé')
AND dc.Statut != 'Refusé'
`);
cpSoldeN = Math.max(0, cpTotalN - consommeResult.recordset[0].total);
}
}
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service,
campus: campusNom,
societe: collab.societe,
typeCongeId: typeCongeMap['Congé payé'],
typeConge: 'Congé payé',
annee: currentYear,
total: parseFloat(cpTotalN.toFixed(2)),
solde: parseFloat(cpSoldeN.toFixed(2)),
consomme: Math.max(0, parseFloat((cpTotalN - cpSoldeN).toFixed(2))),
role: collab.role,
typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
});
// ==================== CP N-1 ====================
const cpN1Result = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['Congé payé'])
.input('annee', sql.Int, previousYear)
.query(`
SELECT Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const cpN1 = cpN1Result.recordset;
if (cpN1.length > 0 ) {
const total = parseFloat(cpN1[0].Total);
const solde = parseFloat(cpN1[0].Solde);
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service,
campus: campusNom,
societe: collab.societe,
typeCongeId: typeCongeMap['Congé payé'],
typeConge: 'Congé payé',
annee: previousYear,
total,
solde,
consomme: Math.max(0, parseFloat((total - solde).toFixed(2))),
role: collab.role,
typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
});
}
}
// ==================== RTT — toujours anneeRTT (2026) ====================
if (typeCongeMap['RTT'] && collab.role !== 'Apprenti' && collab.TypeContrat !== 'Apprentissage') {
const rttNResult = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['RTT'])
.input('annee', sql.Int, anneeRTT)
.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const rttN = rttNResult.recordset;
let rttTotalN, rttSoldeN;
if (rttN.length > 0) {
rttTotalN = parseFloat(rttN[0].Total);
rttSoldeN = parseFloat(rttN[0].Solde);
} else {
const rtt = await calculerAcquisitionRTT(collab.id, new Date());
const consommeRTTResult = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['RTT'])
.input('annee', sql.Int, anneeRTT)
.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = @collabId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée')
AND dc.Statut != 'Refusé'
`);
rttTotalN = rtt.acquisition;
rttSoldeN = Math.max(0, rttTotalN - consommeRTTResult.recordset[0].total);
}
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service,
campus: campusNom,
societe: collab.societe,
typeCongeId: typeCongeMap['RTT'],
typeConge: 'RTT',
annee: anneeRTT,
total: parseFloat(rttTotalN.toFixed(2)),
solde: parseFloat(rttSoldeN.toFixed(2)),
consomme: Math.max(0, parseFloat((rttTotalN - rttSoldeN).toFixed(2))),
role: collab.role,
typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
});
}
// ==================== Récupération — toujours anneeRecup (2026) ====================
if (typeCongeMap['Récupération']) {
const recupNResult = await pool.request()
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['Récupération'])
.input('annee', sql.Int, anneeRecup)
.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const recupN = recupNResult.recordset;
if (recupN.length > 0) {
const recupTotal = parseFloat(recupN[0].Total);
const recupSolde = parseFloat(recupN[0].Solde);
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service,
campus: campusNom,
societe: collab.societe,
typeCongeId: typeCongeMap['Récupération'],
typeConge: 'Récupération',
annee: anneeRecup,
total: recupTotal,
solde: recupSolde,
consomme: Math.max(0, parseFloat((recupTotal - recupSolde).toFixed(2))),
role: collab.role,
typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
});
}
}
}
console.log(`${resultats.length} compteurs détaillés générés`);
res.json(resultats);
} catch (err) {
console.error('❌ Erreur GET ALL:', err);
res.status(500).json({ error: err.message });
}
});
// ================================================
// ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD
// ================================================
// GET /api/collaborateurs/details - Liste complète des collaborateurs avec toutes leurs infos
app.get('/collaborateurs/details', authenticateToken, async (req, res) => {
try {
if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const result = await pool.request()
.input('currentYear', sql.Int, currentYear)
.input('previousYear', sql.Int, previousYear)
.query(`
SELECT
ca.id,
ca.nom,
ca.prenom,
ca.email,
ca.description as poste,
ca.role,
ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree,
ca.actif as Actif,
-- Société
COALESCE(soc.Nom, 'Sans société') as societe,
ca.SocieteId as societeId,
-- Campus
COALESCE(camp.Nom, 'Sans campus') as campus,
ca.CampusId as campusId,
-- Service
COALESCE(serv.Nom, 'Sans service') as service,
ca.ServiceId as serviceId,
-- Hiérarchie - ✅ CORRECTION : Utiliser [SuperieurIdn+2] avec crochets
hv.SuperieurId as nPlus1Id,
CONCAT(n1.prenom, ' ', n1.nom) as nPlus1Nom,
hv.[SuperieurIdn+2] as nPlus2Id, -- ✅ CROCHETS pour le +
CONCAT(n2.prenom, ' ', n2.nom) as nPlus2Nom,
NULL as nPlus3Id,
NULL as nPlus3Nom,
-- Compteurs
compteurs.soldeCPN,
compteurs.soldeCPNMoins1,
compteurs.soldeRTT,
compteurs.soldeRecup
FROM CollaborateurAD ca
LEFT JOIN Societe soc ON ca.SocieteId = soc.Id
LEFT JOIN Campus camp ON ca.CampusId = camp.Id
LEFT JOIN Services serv ON ca.ServiceId = serv.Id
LEFT JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId
LEFT JOIN CollaborateurAD n1 ON hv.SuperieurId = n1.id
LEFT JOIN CollaborateurAD n2 ON hv.[SuperieurIdn+2] = n2.id -- ✅ CROCHETS
OUTER APPLY (
SELECT
MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeCPN,
MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @previousYear THEN cc.Solde END) as soldeCPNMoins1,
MAX(CASE WHEN tc.Nom = 'RTT' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRTT,
MAX(CASE WHEN tc.Nom = 'Récupération' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRecup
FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.CollaborateurADId = ca.id
AND tc.Nom IN ('Congé payé', 'RTT', 'Récupération')
AND cc.Annee IN (@currentYear, @previousYear)
) compteurs
WHERE ca.actif = 1 OR ca.actif IS NULL
ORDER BY soc.Nom, camp.Nom, serv.Nom, ca.nom, ca.prenom
`);
const collaborateurs = result.recordset.map(row => ({
...row,
soldeCPN: row.soldeCPN || 0,
soldeCPNMoins1: row.soldeCPNMoins1 || 0,
soldeRTT: row.soldeRTT || 0,
soldeRecup: row.soldeRecup || 0
}));
console.log(`${collaborateurs.length} collaborateurs récupérés avec détails`);
res.json(collaborateurs);
} catch (error) {
console.error('❌ Erreur /collaborateurs/details:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/societes - Liste des sociétés
app.get('/societes', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT Id as id, Nom as nom
FROM Societe
ORDER BY Nom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /societes:', error);
res.status(500).json({ error: error.message });
}
});
const CAMPUS_MAPPING = {
1: 'Cergy',
2: 'Saint-Quentin-en-Yvelines',
3: 'Marseille',
4: 'Nantes'
// Ajouter les autres campus selon tes besoins
};
// GET /api/campus - Liste des campus
app.get('/campus', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT Id as id, Nom as nom
FROM Campus
ORDER BY Nom
`);
console.log('✅ Campus récupérés:', result.recordset);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /campus:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/services - Liste des services
app.get('/services', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT Id as id, Nom as nom
FROM Services
ORDER BY Nom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /services:', error);
res.status(500).json({ error: error.message });
}
});
// PUT /api/collaborateurs/:id - Modifier un collaborateur
// GET /api/collaborateurs/validateurs - Liste des collaborateurs actifs pouvant être validateurs
app.get('/collaborateurs/validateurs', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT id, CONCAT(prenom, ' ', nom) as nom, email, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (
role LIKE '%Admin%'
OR role LIKE '%RH%'
OR role LIKE '%Validat%'
OR role LIKE '%Directeur%'
OR role LIKE '%Directrice%'
OR role LIKE '%President%'
OR role LIKE '%Président%'
)
ORDER BY nom, prenom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /collaborateurs/validateurs:', error);
res.status(500).json({ error: error.message });
}
});
app.put('/collaborateurs/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
if (!['Admin', 'RH'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { id } = req.params;
const {
nom,
prenom,
email,
societeId,
campusId,
serviceId,
poste,
role,
typeContrat,
dateEntree,
nPlus1Id,
nPlus2Id
} = req.body;
console.log('Modification collaborateur', id, req.body);
// Vérifier que le collaborateur existe
const existingResult = await transaction.request()
.input('id', sql.Int, id)
.query(`SELECT id, prenom, nom FROM CollaborateurAD WHERE id = @id`);
if (existingResult.recordset.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Collaborateur non trouvé' });
}
// Mise à jour de CollaborateurAD
await transaction.request()
.input('id', sql.Int, id)
.input('nom', sql.NVarChar(100), nom)
.input('prenom', sql.NVarChar(100), prenom)
.input('email', sql.NVarChar(255), email)
.input('societeId', sql.Int, societeId)
.input('campusId', sql.Int, campusId)
.input('serviceId', sql.Int, serviceId)
.input('typeContrat', sql.NVarChar(50), typeContrat)
.input('dateEntree', sql.Date, dateEntree || null)
.input('description', sql.NVarChar(500), poste || null)
.input('role', sql.NVarChar(100), role || null)
.query(`
UPDATE CollaborateurAD
SET nom = @nom,
prenom = @prenom,
email = @email,
SocieteId = @societeId,
CampusId = @campusId,
ServiceId = @serviceId,
TypeContrat = @typeContrat,
DateEntree = @dateEntree,
description = @description,
role = @role
WHERE id = @id
`);
// Mise à jour de la hiérarchie - ✅ CORRECTION avec crochets
const hierarchieExistResult = await transaction.request()
.input('collaborateurId', sql.Int, id)
.query(`SELECT CollaborateurId FROM HierarchieValidationAD WHERE CollaborateurId = @collaborateurId`);
if (hierarchieExistResult.recordset.length > 0) {
// UPDATE
await transaction.request()
.input('collaborateurId', sql.Int, id)
.input('superieurId', sql.Int, nPlus1Id)
.input('superieurIdN2', sql.Int, nPlus2Id)
.query(`
UPDATE HierarchieValidationAD
SET SuperieurId = @superieurId,
[SuperieurIdn+2] = @superieurIdN2
WHERE CollaborateurId = @collaborateurId
`);
} else {
// INSERT
if (nPlus1Id || nPlus2Id) {
await transaction.request()
.input('collaborateurId', sql.Int, id)
.input('superieurId', sql.Int, nPlus1Id)
.input('superieurIdN2', sql.Int, nPlus2Id)
.query(`
INSERT INTO HierarchieValidationAD (CollaborateurId, SuperieurId, [SuperieurIdn+2])
VALUES (@collaborateurId, @superieurId, @superieurIdN2)
`);
}
}
// Enregistrer dans l'historique
try {
await transaction.request()
.input('userId', sql.Int, req.user.id)
.input('details', sql.NVarChar(500), `Modification des informations de ${prenom} ${nom}`)
.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (@userId, 'Modification collaborateur', @details, GETDATE())
`);
} catch (histError) {
console.warn('Erreur historique non bloquant:', histError.message);
}
await transaction.commit();
console.log(`✅ Collaborateur ${id} mis à jour`);
res.json({ success: true, message: 'Collaborateur mis à jour avec succès' });
} catch (error) {
await transaction.rollback();
console.error('❌ Erreur PUT /collaborateurs/:id:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/snapshot/historique/:employeeId
app.get('/snapshot/historique/:employeeId', authenticateToken, async (req, res) => {
try {
const { employeeId } = req.params;
const { typeCongeId, typeConge, annee } = req.query;
const request = pool.request()
.input('employeeId', sql.Int, parseInt(employeeId));
let where = 'WHERE s.CollaborateurADId = @employeeId';
// ⭐ Filtrer par ID ou par nom selon ce qui est fourni
if (typeCongeId) {
request.input('typeCongeId', sql.Int, parseInt(typeCongeId));
where += ' AND s.TypeCongeId = @typeCongeId';
} else if (typeConge) {
request.input('typeCongeNom', sql.NVarChar, typeConge);
where += ' AND tc.Nom = @typeCongeNom';
}
if (annee) {
request.input('annee', sql.Int, parseInt(annee));
where += ' AND s.Annee = @annee';
}
const result = await request.query(`
SELECT
s.DateSnapshot,
s.Solde,
s.SoldeAnticipe,
s.Total,
s.SoldeReporte,
ISNULL(s.IsAnticipe, 0) AS IsAnticipe,
tc.Nom AS typeConge,
s.Annee,
-- ⭐ Récupérer SoldeAnticipe depuis CompteurConges (valeur actuelle)
ISNULL(cc.SoldeAnticipe, 0) AS SoldeAnticipeActuel
FROM SnapshotQuotidienCompteurs s
JOIN TypeConge tc ON s.TypeCongeId = tc.Id
LEFT JOIN CompteurConges cc
ON cc.CollaborateurADId = s.CollaborateurADId
AND cc.TypeCongeId = s.TypeCongeId
AND cc.Annee = s.Annee
${where}
ORDER BY s.DateSnapshot ASC
`);
res.json(result.recordset || []);
} catch (error) {
console.error('❌ Erreur snapshot historique:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================
// ⭐ ROUTE GET /soldes-previsionnel
// Calcule les soldes CP et RTT projetés à la date de début du congé,
// en lisant DeductionDetails exactement comme serverGTA.
// Gère NORMAL et ANTICIPÉ (CP + RTT).
// Usage: GET /api/soldes-previsionnel?userid=X&dateDebut=YYYY-MM-DD&joursCP=N&joursRTT=N
// ================================================
app.get('/soldes-previsionnel', authenticateToken, async (req, res) => {
const { userid, dateDebut, joursCP, joursRTT } = req.query;
if (!userid || !dateDebut) {
return res.json({ success: false, message: 'Paramètres requis: userid, dateDebut' });
}
const userIdInt = parseInt(userid, 10);
if (isNaN(userIdInt)) return res.json({ success: false, message: 'userid invalide' });
try {
const today = new Date(); today.setHours(0, 0, 0, 0);
const currentYear = today.getFullYear();
const dateDebutObj = new Date(dateDebut); dateDebutObj.setHours(0, 0, 0, 0);
const moisDebut = dateDebutObj.getMonth() + 1;
const anneeDebut = dateDebutObj.getFullYear();
// ── Infos collaborateur ────────────────────────────────────────
const collabRes = await pool.request()
.input('userId', sql.Int, userIdInt)
.query(`SELECT TOP 1 role, DateEntree, TypeContrat FROM CollaborateurAD WHERE id=@userId`);
if (!collabRes.recordset.length) throw new Error('Collaborateur introuvable');
const isApprenti = collabRes.recordset[0].role === 'Apprenti';
const dateEntree = collabRes.recordset[0].DateEntree ?? null;
const typeContrat = collabRes.recordset[0].TypeContrat ?? '37h';
// ── Exercice CP (Annee = année de FIN d'exercice) ─────────────
const dateBasculeCP = new Date(currentYear, 5, 1); dateBasculeCP.setHours(0, 0, 0, 0);
const apresBasculement = today >= dateBasculeCP;
const anneeN = apresBasculement ? currentYear + 1 : currentYear;
const anneeN1 = anneeN - 1;
const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
const isAnticipe = anneeExConge > anneeN;
// ── Exercice RTT (année civile) ────────────────────────────────
const anneeRTT = anneeDebut;
const isAnticipeRTT = anneeRTT > currentYear;
// ── TypeConge IDs ──────────────────────────────────────────────
const cpTypeRes = await pool.request()
.input('nom', sql.NVarChar, 'Congé payé')
.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`);
if (!cpTypeRes.recordset.length) throw new Error('Type CP introuvable');
const cpTypeId = cpTypeRes.recordset[0].Id;
const rttTypeRes = await pool.request()
.input('nom', sql.NVarChar, 'RTT')
.query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`);
const rttTypeId = rttTypeRes.recordset[0]?.Id ?? null;
// ── Helper : lire compteur CP ──────────────────────────────────
const lireCompteurCP = async (annee) => {
const r = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, cpTypeId)
.input('annee', sql.Int, annee)
.query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(SoldeReporte,0) AS FLOAT) AS SoldeReporte, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`);
if (!r.recordset.length) return null;
const row = r.recordset[0];
const solde = parseFloat(row.Solde);
const report = parseFloat(row.SoldeReporte);
return { solde, reporte: report, total: parseFloat(row.Total), dispoN1: Math.max(0, solde), dispoN: Math.max(0, solde - report) };
};
// ── Helper : lire compteur RTT ─────────────────────────────────
const lireCompteurRTT = async (annee) => {
if (!rttTypeId) return null;
const r = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, rttTypeId)
.input('annee', sql.Int, annee)
.query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`);
if (!r.recordset.length) return null;
return { solde: parseFloat(r.recordset[0].Solde), total: parseFloat(r.recordset[0].Total) };
};
// ── Compteurs CP ───────────────────────────────────────────────
const cptN1 = await lireCompteurCP(anneeN1);
const cptN = await lireCompteurCP(anneeN);
const soldeNActuel = cptN ? Math.max(0, parseFloat(cptN.solde || 0)) : 0;
const soldeN1Actuel = cptN1 ? Math.max(0, parseFloat(cptN1.solde || 0)) : 0;
// ── Déductions futures sur CP N (après dateDebutObj) ──────────
const dejaConsoFuturNRes = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, cpTypeId)
.input('annee', sql.Int, anneeN)
.input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const dejaConsoFuturN = parseFloat(dejaConsoFuturNRes.recordset[0]?.total ?? 0);
// ── Déductions futures sur CP N-1 (après dateDebutObj) ────────
const dejaConsoFuturN1Res = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, cpTypeId)
.input('annee', sql.Int, anneeN1)
.input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const dejaConsoFuturN1 = parseFloat(dejaConsoFuturN1Res.recordset[0]?.total ?? 0);
// ── Soldes disponibles ─────────────────────────────────────────
const cpN1Dispo = Math.max(0, soldeN1Actuel - dejaConsoFuturN1);
const cpNAcquisAujourdHui = Math.max(0, soldeNActuel - dejaConsoFuturN);
let totalCPDisponible;
let cpNAnticipeDispo = 0;
let acquisCPADateDebut = 0;
let cpSoldeADateDebut = 0;
if (!isAnticipe) {
// ── MODE NORMAL : calculerAcquisitionFormuleExcel au jour près ──
const acquisADateDebut = calculerAcquisitionFormuleExcel(
today,
dateDebutObj,
25 / 12
);
const soldeNALaDate = parseFloat(
Math.min(25, soldeNActuel + acquisADateDebut).toFixed(2)
);
acquisCPADateDebut = soldeNALaDate;
cpSoldeADateDebut = Math.max(0, soldeNALaDate - dejaConsoFuturN);
cpNAnticipeDispo = Math.max(0, cpSoldeADateDebut - cpNAcquisAujourdHui);
totalCPDisponible = parseFloat(
Math.max(0, cpN1Dispo + cpSoldeADateDebut).toFixed(2)
);
console.log(`🔍 CP NORMAL: solde actuel=${soldeNActuel}j + acq futures=${acquisADateDebut.toFixed(2)}j = à date ${soldeNALaDate}j - conso future=${dejaConsoFuturN}j = ${cpSoldeADateDebut}j | N-1=${cpN1Dispo}j | Total=${totalCPDisponible}j`);
} else {
// ── MODE ANTICIPÉ ────────────────────────────────────────────
// Fin exercice N = 31/05/currentYear (toujours l'année civile courante)
const finExN = new Date(currentYear, 4, 31);
finExN.setHours(23, 59, 59, 0);
const debutNp1 = new Date(currentYear, 5, 1); // 01/06/currentYear
debutNp1.setHours(0, 0, 0, 0);
// Acquisitions depuis aujourd'hui jusqu'au 31/05 (peut être 0 si passé)
const acquisJusquAuFinExN = today <= finExN
? calculerAcquisitionFormuleExcel(today, finExN, 25 / 12)
: 0;
const soldeNAFinEx = parseFloat(
Math.min(25, soldeNActuel + acquisJusquAuFinExN).toFixed(2)
);
// Acquisitions N+1 depuis 01/06/currentYear jusqu'à dateDebutObj
const acquisNp1 = dateDebutObj > debutNp1
? parseFloat(Math.min(25, calculerAcquisitionFormuleExcel(debutNp1, dateDebutObj, 25 / 12)).toFixed(2))
: 0;
// Déductions anticipées APRÈS dateDebutObj uniquement
const dejaConsoAnticipRes = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, cpTypeId)
.input('annee', sql.Int, anneeN)
.input('dateDebut', sql.Date, dateDebutObj)
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dd.TypeDeduction IN ('Anticip','AnticipFutur')
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const dejaConsoAnticip = parseFloat(dejaConsoAnticipRes.recordset[0]?.total ?? 0);
// Déductions sur N+1 après dateDebutObj
const dejaConsoNp1Res = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, cpTypeId)
.input('annee', sql.Int, anneeExConge)
.input('dateDebut', sql.Date, dateDebutObj)
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const dejaConsoNp1 = parseFloat(dejaConsoNp1Res.recordset[0]?.total ?? 0);
const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
acquisCPADateDebut = soldeNAFinEx;
cpSoldeADateDebut = Math.max(0, soldeNAFinEx - dejaConsoAnticip);
cpNAnticipeDispo = dispoNp1;
totalCPDisponible = parseFloat(
Math.max(0, cpN1Dispo + cpSoldeADateDebut + dispoNp1).toFixed(2)
);
console.log(`🔍 CP ANTICIPÉ: solde=${soldeNActuel}j + acq→31/05=${acquisJusquAuFinExN.toFixed(2)}j = ${soldeNAFinEx}j - conso après dateDebut=${dejaConsoAnticip}j | N+1 (01/06→${dateDebut})=${dispoNp1}j | Total=${totalCPDisponible}j`);
}
console.log(`🔍 CP — N-1: ${cpN1Dispo.toFixed(2)}j | N acquis: ${cpNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${cpNAnticipeDispo.toFixed(2)}j | Total: ${totalCPDisponible.toFixed(2)}j`);
// ── RTT ────────────────────────────────────────────────────────
let rttDispo = 0, acquisRTTADateDebut = 0, rttNAcquisAujourdHui = 0, rttAnticipeDispo = 0;
if (rttTypeId && !isApprenti) {
const rttData = await calculerAcquisitionRTT(userIdInt, dateDebutObj);
acquisRTTADateDebut = rttData.acquisition;
const cptRTT = await lireCompteurRTT(anneeRTT);
rttNAcquisAujourdHui = cptRTT ? Math.max(0, cptRTT.solde) : 0;
const rttConsoFuturRes = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, rttTypeId)
.input('annee', sql.Int, anneeRTT)
.input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const rttConsoFutur = parseFloat(rttConsoFuturRes.recordset[0]?.total ?? 0);
if (!isAnticipeRTT) {
const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat);
const acquisFutRTT = parseFloat(
Math.min(cfgRTT.joursAnnuels - rttNAcquisAujourdHui,
calculerAcquisitionFormuleExcel(today, dateDebutObj, cfgRTT.joursAnnuels / 12)
).toFixed(2)
);
const rttALaDate = parseFloat(
Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisFutRTT).toFixed(2)
);
rttAnticipeDispo = Math.max(0, rttALaDate - rttNAcquisAujourdHui);
rttDispo = Math.max(0, rttALaDate - rttConsoFutur);
} else {
const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat);
const finExRTT = new Date(anneeRTT, 11, 31); finExRTT.setHours(23, 59, 59, 0);
const acquisRest = Math.min(
cfgRTT.joursAnnuels - rttNAcquisAujourdHui,
calculerAcquisitionFormuleExcel(today, finExRTT, cfgRTT.joursAnnuels / 12)
);
const soldeProj = Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisRest);
const dispoRTTN = Math.max(0, soldeProj - rttConsoFutur);
const anneeRTTNp1 = anneeRTT + 1;
const cfgRTTNp1 = await getConfigurationRTT(anneeRTTNp1, typeContrat);
const debutNp1RTT = new Date(anneeRTTNp1, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0);
const acquisNp1RTT = parseFloat(
Math.min(cfgRTTNp1.joursAnnuels,
calculerAcquisitionFormuleExcel(debutNp1RTT, dateDebutObj, cfgRTTNp1.joursAnnuels / 12)
).toFixed(2)
);
const rttConsoNp1Res = await pool.request()
.input('userId', sql.Int, userIdInt)
.input('typeId', sql.Int, rttTypeId)
.input('annee', sql.Int, anneeRTTNp1)
.input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj
.query(`
SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = @userId
AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee
AND dc.DateDebut > @dateDebut
AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
`);
const rttConsoNp1 = parseFloat(rttConsoNp1Res.recordset[0]?.total ?? 0);
const dispoNp1RTT = parseFloat(Math.max(0, acquisNp1RTT - rttConsoNp1).toFixed(2));
rttDispo = parseFloat((dispoRTTN + dispoNp1RTT).toFixed(2));
rttAnticipeDispo = dispoNp1RTT;
console.log(`🔍 RTT ANTICIPÉ: N=${dispoRTTN.toFixed(2)}j + N+1=${dispoNp1RTT.toFixed(2)}j = ${rttDispo.toFixed(2)}j`);
}
console.log(`🔍 RTT (${anneeRTT}) — Acquis à dateDebut: ${acquisRTTADateDebut.toFixed(2)}j | En base: ${rttNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${rttAnticipeDispo.toFixed(2)}j | Dispo: ${rttDispo.toFixed(2)}j`);
}
// ── Vérification suffisance ────────────────────────────────────
const joursNecessairesCP = parseFloat(joursCP) || 0;
const joursNecessairesRTT = parseFloat(joursRTT) || 0;
const suffisantCP = joursNecessairesCP <= 0 || totalCPDisponible >= joursNecessairesCP;
const suffisantRTT = joursNecessairesRTT <= 0 || rttDispo >= joursNecessairesRTT;
const suffisant = suffisantCP && suffisantRTT;
const deficitCP = suffisantCP ? 0 : parseFloat((joursNecessairesCP - totalCPDisponible).toFixed(2));
const deficitRTT = suffisantRTT ? 0 : parseFloat((joursNecessairesRTT - rttDispo).toFixed(2));
let modeLabel = '';
if (!isAnticipe) modeLabel = apresBasculement ? 'NORMAL POST-BASCULEMENT' : 'NORMAL';
else if (!apresBasculement) modeLabel = 'ANTICIPÉ CLASSIQUE';
else modeLabel = 'ANTICIPÉ POST-BASCULEMENT';
const exerciceDemande = anneeN;
const exerciceConges = anneeExConge - 1;
res.json({
success: true,
previsionnel: {
mode: modeLabel,
isAnticipe,
isAnticipeRTT,
apresBasculement,
exerciceDemande: `${exerciceDemande}-${exerciceDemande + 1}`,
exerciceConges: `${exerciceConges}-${exerciceConges + 1}`,
anneeN1, anneeN, anneeRTT,
cpN1Reporte: parseFloat(cpN1Dispo.toFixed(2)),
cpNSolde: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
cpNSoldeALaDate: parseFloat(cpSoldeADateDebut.toFixed(2)),
cpNAnticipeAutorise: parseFloat(cpNAnticipeDispo.toFixed(2)),
acquisCPADateDebut: parseFloat(acquisCPADateDebut.toFixed(2)),
cpNAcquisAujourdHui: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
cpNAnticipeDispo: parseFloat(cpNAnticipeDispo.toFixed(2)),
totalCPDisponible,
acquisRTTADateDebut: parseFloat(acquisRTTADateDebut.toFixed(2)),
rttNAcquisAujourdHui: parseFloat(rttNAcquisAujourdHui.toFixed(2)),
rttAnticipeDispo: parseFloat(rttAnticipeDispo.toFixed(2)),
rttDispo: parseFloat(rttDispo.toFixed(2)),
joursNecessairesCP, joursNecessairesRTT,
totalDisponible: totalCPDisponible,
suffisant, suffisantCP, suffisantRTT,
deficitCP, deficitRTT,
message: (() => {
const parts = [];
if (cpN1Dispo > 0) parts.push(`N-1(${anneeN1}): ${cpN1Dispo.toFixed(2)}j`);
if (cpNAcquisAujourdHui > 0) parts.push(`N acquis(${anneeN}): ${cpNAcquisAujourdHui.toFixed(2)}j`);
if (cpNAnticipeDispo > 0) parts.push(`N anticipé: ${cpNAnticipeDispo.toFixed(2)}j`);
if (rttDispo > 0) parts.push(`RTT(${anneeRTT}): ${rttDispo.toFixed(2)}j`);
return `${modeLabel}${parts.join(' + ')} → CP total: ${totalCPDisponible.toFixed(2)}j`;
})()
}
});
} catch (error) {
console.error('❌ Erreur soldes-previsionnel:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ================================================
// ROUTE PUT /demandes/:id/annuler - ANNULATION SANS SUPPRESSION
// ================================================
app.put('/demandes/:id/annuler', authenticateToken, async (req, res) => {
const demandeId = parseInt(req.params.id);
const { commentaire } = req.body;
console.log(`🚫 Annulation demande #${demandeId}`);
const transaction = new sql.Transaction();
try {
await transaction.begin();
// 1. Récupérer la demande
const demandeResult = await transaction.request()
.input('demandeId', sql.Int, demandeId)
.query(`
SELECT
dc.*,
c.Email as EmailEmploye,
c.Nom,
c.Prenom
FROM DemandeConge dc
INNER JOIN CollaborateurAD c ON dc.CollaborateurADId = c.Id
WHERE dc.Id = @demandeId
`);
if (demandeResult.recordset.length === 0) {
await transaction.rollback();
return res.status(404).json({ error: 'Demande non trouvée' });
}
const demande = demandeResult.recordset[0];
const collaborateurId = demande.CollaborateurADId;
const ancienStatut = demande.Statut;
console.log(`📋 Demande trouvée - Statut actuel: ${ancienStatut}`);
// 2. Récupérer les types de congés
const typesResult = await transaction.request()
.input('demandeId', sql.Int, demandeId)
.query(`
SELECT
dct.Id,
dct.TypeCongeId,
dct.NombreJours,
tc.Nom as TypeConge
FROM DemandeCongeType dct
INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dct.DemandeCongeId = @demandeId
`);
console.log(`📊 ${typesResult.recordset.length} type(s) de congé trouvé(s)`);
// 3. ⭐ RESTAURATION VIA DeductionDetails (même logique que serverGTA)
if (ancienStatut === 'En attente' || ancienStatut === 'Validée') {
console.log('💰 Restauration des compteurs via DeductionDetails...');
// Lire toutes les lignes DeductionDetails de cette demande
const deductionsResult = await transaction.request()
.input('demandeId', sql.Int, demandeId)
.query(`
SELECT dd.Id AS DeductionId, dd.TypeCongeId, dd.Annee,
dd.TypeDeduction, dd.JoursUtilises, tc.Nom AS TypeNom
FROM DeductionDetails dd
JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
WHERE dd.DemandeCongeId = @demandeId
ORDER BY dd.Id DESC
`);
const deductions = deductionsResult.recordset;
console.log(`📊 ${deductions.length} ligne(s) DeductionDetails trouvée(s)`);
const restorations = [];
for (const ded of deductions) {
const { DeductionId, TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = ded;
const jours = parseFloat(JoursUtilises);
console.log(`\n🔍 ${TypeNom} - ${TypeDeduction} - ${jours}j (Année: ${Annee})`);
// Helper : lire compteur
const lireCpt = async (annee) => {
const r = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, TypeCongeId)
.input('annee', sql.Int, annee)
.query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
return r.recordset[0] ?? null;
};
// ── Année N-1 → Solde + SoldeReporte ─────────────────
if (TypeDeduction === 'Année N-1' || TypeDeduction === 'Anne N-1') {
const cpt = await lireCpt(Annee);
if (cpt) {
await transaction.request()
.input('jours', sql.Decimal(10, 2), jours)
.input('id', sql.Int, cpt.Id)
.query(`UPDATE CompteurConges SET Solde=Solde+@jours, SoldeReporte=SoldeReporte+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
console.log(`✅ Année N-1 restaurée: +${jours}j (Solde + SoldeReporte)`);
}
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
continue;
}
// ── Année N → Solde seulement ─────────────────────────
if (TypeDeduction === 'Année N' || TypeDeduction === 'Anne N') {
const cpt = await lireCpt(Annee);
if (cpt) {
await transaction.request()
.input('jours', sql.Decimal(10, 2), jours)
.input('id', sql.Int, cpt.Id)
.query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
console.log(`✅ Année N restaurée: +${jours}j`);
}
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
continue;
}
// ── Anticip (jours réels déduits du solde CP N) → remettre ──
if (TypeDeduction === 'Anticip') {
const cpt = await lireCpt(Annee);
if (cpt) {
await transaction.request()
.input('jours', sql.Decimal(10, 2), jours)
.input('id', sql.Int, cpt.Id)
.query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
console.log(`✅ Anticip restauré: +${jours}j sur CompteurConges`);
}
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
continue;
}
// ── AnticipFutur (tracé uniquement, solde jamais touché) → juste supprimer ──
if (TypeDeduction === 'AnticipFutur') {
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
console.log(`✅ AnticipFutur supprimé: ${jours}j tracé retiré (aucun solde à remettre)`);
continue;
}
// ── AnticipRTT (solde RTT N réel déduit) → remettre ──
if (TypeDeduction === 'AnticipRTT') {
const cpt = await lireCpt(Annee);
if (cpt) {
await transaction.request()
.input('jours', sql.Decimal(10, 2), jours)
.input('id', sql.Int, cpt.Id)
.query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
console.log(`✅ AnticipRTT restauré: +${jours}j sur RTT ${Annee}`);
}
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
continue;
}
// ── AnticipFuturRTT (tracé uniquement) → juste supprimer ──
if (TypeDeduction === 'AnticipFuturRTT') {
await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
console.log(`✅ AnticipFuturRTT supprimé: ${jours}j tracé retiré (aucun solde à remettre)`);
continue;
}
// ── Cas non reconnu → log warning ───────────────────
console.warn(`⚠️ TypeDeduction non géré: "${TypeDeduction}" — ignoré`);
}
// ── Fallback : si aucune ligne DeductionDetails (anciennes demandes) ──
// On applique l'ancienne logique simple pour rester compatible
if (deductions.length === 0) {
console.log('⚠️ Aucune DeductionDetails — fallback restauration simple');
for (const type of typesResult.recordset) {
const nombreJours = parseFloat(type.NombreJours);
const typeCongeId = type.TypeCongeId;
const anneeDemande = new Date(demande.DateDebut).getFullYear();
const cptFallback = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('typeId', sql.Int, typeCongeId)
.input('annee', sql.Int, anneeDemande)
.query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
if (cptFallback.recordset.length > 0) {
await transaction.request()
.input('jours', sql.Decimal(10, 2), nombreJours)
.input('id', sql.Int, cptFallback.recordset[0].Id)
.query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
console.log(`✅ Fallback: ${type.TypeConge} +${nombreJours}j`);
}
}
}
// Historique global
try {
await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('details', sql.NVarChar(500), `Annulation demande #${demandeId} - Restauration compteurs via DeductionDetails`)
.input('demandeId', sql.Int, demandeId)
.query(`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@collabId, 'Restauration compteur', @details, @demandeId, GETDATE())`);
} catch (histErr) {
console.warn('⚠️ Historique non créé (non bloquant):', histErr.message);
}
console.log(`✅ Restauration terminée: ${restorations.length} opération(s)`);
} else {
console.log(`️ Pas de restauration de compteurs (statut était "${ancienStatut}")`);
}
// 4. Mettre à jour le statut de la demande
await transaction.request()
.input('demandeId', sql.Int, demandeId)
.input('commentaire', sql.NVarChar, commentaire || 'Annulée par le service RH')
.query(`
UPDATE DemandeConge
SET Statut = 'Annulée',
Commentaire = @commentaire,
DateValidation = GETDATE()
WHERE Id = @demandeId
`);
await transaction.commit();
console.log(`✅ Demande #${demandeId} annulée avec succès`);
// 5. ✅ CORRECTION : Envoyer un email avec Microsoft Graph
const emailEmploye = demande.EmailEmploye;
const nomComplet = `${demande.Prenom} ${demande.Nom}`;
try {
const accessToken = await getGraphToken();
if (accessToken && emailEmploye) {
const subject = '🚫 Votre demande de congé a été annulée';
const body = `
<div style="font-family: Arial, sans-serif; max-width: 600px;">
<div style="background-color: #ef4444; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0;">🚫 Annulation de demande de congé</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p>Bonjour <strong>${nomComplet}</strong>,</p>
<div style="background-color: white; border-left: 4px solid #ef4444; padding: 15px; margin: 20px 0;">
<p><strong>Période :</strong> ${new Date(demande.DateDebut).toLocaleDateString('fr-FR')} au ${new Date(demande.DateFin).toLocaleDateString('fr-FR')}</p>
<p><strong>Statut :</strong> <span style="color: #ef4444;">Annulée</span></p>
${commentaire ? `<p><strong>Motif :</strong> ${commentaire}</p>` : ''}
</div>
${(ancienStatut === 'En attente' || ancienStatut === 'Validée') ? '<p>️ Les jours de congés ont été automatiquement restitués à votre compteur.</p>' : ''}
<p>Pour toute question, veuillez contacter le service RH.</p>
</div>
</div>
`;
await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', emailEmploye, subject, body);
console.log(`📧 Email d'annulation envoyé à ${emailEmploye}`);
}
} catch (emailError) {
console.error('❌ Erreur envoi email (non bloquant):', emailError.message);
}
// 6. Notification SSE
notifyClients({
type: 'demande-cancelled',
demandeId: demandeId,
collaborateurId: collaborateurId,
message: 'Demande annulée'
});
// 7. Webhook (optionnel)
try {
if (typeof webhookManager !== 'undefined' && (ancienStatut === 'En attente' || ancienStatut === 'Validée')) {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS.URL,
EVENTS.DEMANDE_CANCELLED,
{
demandeId: demandeId,
collaborateur: nomComplet,
dateDebut: demande.DateDebut,
dateFin: demande.DateFin,
ancienStatut: ancienStatut,
compteursRestaurés: true
}
);
}
} catch (webhookError) {
console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
}
res.json({
success: true,
message: 'Demande annulée avec succès',
compteursRestaurés: ancienStatut === 'En attente' || ancienStatut === 'Validée'
});
} catch (error) {
// ✅ CORRECTION : Vérifier si la transaction est toujours active avant rollback
try {
if (transaction._acquiredConnection) {
await transaction.rollback();
console.log('🔄 Transaction annulée (rollback)');
}
} catch (rollbackError) {
console.error('⚠️ Erreur rollback:', rollbackError.message);
}
console.error('❌ Erreur annulation demande:', error);
res.status(500).json({
error: 'Erreur lors de l\'annulation de la demande',
details: error.message
});
}
});
// PUT /api/compte-rendu/jour - RH modifie un jour du compte-rendu d'activité
app.put('/compte-rendu/jour', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
await transaction.begin();
if (!['Admin', 'RH'].includes(req.user.role)) {
await transaction.rollback();
return res.status(403).json({ success: false, error: 'Accès non autorisé' });
}
const { collaborateurid, jourdate, jourtravaille, reposquotidien, reposhebdo, commentaire } = req.body;
if (!collaborateurid || !jourdate) {
await transaction.rollback();
return res.status(400).json({ success: false, error: 'collaborateurid et jourdate sont requis' });
}
const collaborateurId = parseInt(collaborateurid);
const dateObj = new Date(jourdate);
const annee = dateObj.getFullYear();
const mois = dateObj.getMonth() + 1;
// UPSERT du jour dans CompteRenduActivites
const existing = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('jourDate', sql.Date, jourdate)
.query(`SELECT Id FROM CompteRenduActivites
WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`);
if (existing.recordset.length > 0) {
await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('jourDate', sql.Date, jourdate)
.input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0)
.input('reposQ', sql.Bit, reposquotidien ? 1 : 0)
.input('reposH', sql.Bit, reposhebdo ? 1 : 0)
.input('commentaire', sql.NVarChar(500), commentaire || '')
.query(`UPDATE CompteRenduActivites SET
JourTravaille = @jourTravaille,
ReposQuotidienRespect = @reposQ,
ReposHebdomadaireRespect = @reposH,
CommentaireRepos = @commentaire,
DateModification = GETDATE()
WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`);
} else {
// ✅ Générer l'ID manuellement
const maxIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduActivites');
const newId = maxIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, newId)
.input('collaborateurId', sql.Int, collaborateurId)
.input('jourDate', sql.Date, jourdate)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0)
.input('reposQ', sql.Bit, reposquotidien ? 1 : 0)
.input('reposH', sql.Bit, reposhebdo ? 1 : 0)
.input('commentaire', sql.NVarChar(500), commentaire || '')
.query(`INSERT INTO CompteRenduActivites
(Id, CollaborateurADId, JourDate, Annee, Mois, JourTravaille,
ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, DateModification)
VALUES
(@id, @collaborateurId, @jourDate, @annee, @mois, @jourTravaille,
@reposQ, @reposH, @commentaire, GETDATE(), GETDATE())`);
}
// Recalculer et mettre à jour CompteRenduMensuel
const statsResult = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`SELECT
COUNT(CASE WHEN JourTravaille = 1 THEN 1 END) AS NbJoursTravailles,
COUNT(CASE WHEN JourTravaille = 1 AND ReposQuotidienRespect = 0 THEN 1 END) AS NbReposQ,
COUNT(CASE WHEN JourTravaille = 1 AND ReposHebdomadaireRespect = 0 THEN 1 END) AS NbReposH
FROM CompteRenduActivites
WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
const stats = statsResult.recordset[0];
const mensuelExist = await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`SELECT Id FROM CompteRenduMensuel
WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
if (mensuelExist.recordset.length > 0) {
await transaction.request()
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles)
.input('nbReposQ', sql.Int, stats.NbReposQ)
.input('nbReposH', sql.Int, stats.NbReposH)
.query(`UPDATE CompteRenduMensuel SET
NbJoursTravailles = @nbJours,
NbJoursNonRespectsReposQuotidien = @nbReposQ,
NbJoursNonRespectsReposHebdo = @nbReposH
WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
} else {
// ✅ Générer l'ID manuellement pour CompteRenduMensuel aussi
const maxMensuelIdResult = await transaction.request()
.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduMensuel');
const newMensuelId = maxMensuelIdResult.recordset[0].NextId;
await transaction.request()
.input('id', sql.Int, newMensuelId)
.input('collaborateurId', sql.Int, collaborateurId)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles)
.input('nbReposQ', sql.Int, stats.NbReposQ)
.input('nbReposH', sql.Int, stats.NbReposH)
.query(`INSERT INTO CompteRenduMensuel
(Id, CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille)
VALUES (@id, @collaborateurId, @annee, @mois, @nbJours, @nbReposQ, @nbReposH, 0)`);
}
await transaction.commit();
res.json({ success: true, message: 'Jour mis à jour avec succès' });
} catch (error) {
try { await transaction.rollback(); } catch (e) { }
console.error('Erreur PUT compte-rendu/jour', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Verrouiller un mois
app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => {
try {
const { collaborateurid, annee, mois } = req.body;
// Upsert : créer la ligne si elle n'existe pas, puis verrouiller
const existing = await pool.request()
.input('collaborateurId', sql.Int, collaborateurid)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`SELECT Id FROM CompteRenduMensuel
WHERE CollaborateurADId = @collaborateurId
AND Annee = @annee AND Mois = @mois`);
if (existing.recordset.length > 0) {
await pool.request()
.input('collaborateurId', sql.Int, collaborateurid)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`UPDATE CompteRenduMensuel
SET Verrouille = 1
WHERE CollaborateurADId = @collaborateurId
AND Annee = @annee AND Mois = @mois`);
} else {
// Créer l'entrée mensuelle si elle n'existe pas encore
await pool.request()
.input('collaborateurId', sql.Int, collaborateurid)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`INSERT INTO CompteRenduMensuel
(CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille)
VALUES (@collaborateurId, @annee, @mois, 0, 0, 0, 1)`);
}
res.json({ success: true, message: 'Mois verrouillé' });
} catch (error) {
console.error('Erreur verrouillage:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Déverrouiller un mois
app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => {
try {
const { collaborateurid, annee, mois } = req.body;
await pool.request()
.input('collaborateurId', sql.Int, collaborateurid)
.input('annee', sql.Int, annee)
.input('mois', sql.Int, mois)
.query(`UPDATE CompteRenduMensuel
SET Verrouille = 0
WHERE CollaborateurADId = @collaborateurId
AND Annee = @annee AND Mois = @mois`);
res.json({ success: true, message: 'Mois déverrouillé' });
} catch (error) {
console.error('Erreur déverrouillage:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ================================================
// CRON — Snapshot quotidien à 23h55 heure de Paris
// ================================================
function planifierSnapshotQuotidien() {
const maintenant = new Date();
const prochainDeclenchement = new Date();
prochainDeclenchement.setHours(23, 55, 0, 0);
// Si 23h55 est déjà passé aujourd'hui, programmer pour demain
if (prochainDeclenchement <= maintenant) {
prochainDeclenchement.setDate(prochainDeclenchement.getDate() + 1);
}
const delai = prochainDeclenchement - maintenant;
console.log(`[CRON] ✅ Snapshot planifié dans ${Math.round(delai / 1000 / 60)} minutes`);
setTimeout(async () => {
await executerSnapshotQuotidien();
// Re-planifier pour le lendemain
planifierSnapshotQuotidien();
}, delai);
}
planifierSnapshotQuotidien();
// ================================================
// DÉMARRAGE DU SERVEUR
// ================================================
console.log('✅ 99. Toutes les routes définies, démarrage du serveur...');
const server = app.listen(PORT, '0.0.0.0', () => { // ← AJOUT DE '0.0.0.0'
console.log('\n================================================');
console.log(`✅ SERVEUR RH DÉMARRÉ sur http://0.0.0.0:${PORT}`); // ← Changé localhost en 0.0.0.0
console.log('🔔 Server-Sent Events activés sur /events');
console.log('🔗 WEBHOOKS configurés:');
console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`);
console.log(` - Route webhook receiver: POST /webhook/receive`);
console.log('');
console.log('📋 Routes disponibles:');
console.log(' - GET /events (SSE)');
console.log(' - POST /webhook/receive');
console.log(' - POST /login-dev ✅ SQL SERVER');
console.log(' - GET /demandes');
console.log(' - GET /demandes/:id');
console.log(' - POST /demandes');
console.log(' - PUT /demandes/:id');
console.log(' - DELETE /demandes/:id');
console.log(' - PUT /demandes/:id/valider');
console.log(' - GET /employes');
console.log(' - POST /employes/desactiver');
console.log(' - POST /employes/reactiver');
console.log(' - GET /types-conge');
console.log(' - GET /export/paie');
console.log(' - GET /compteurs');
console.log(' - POST /reinitializeAllCounters ✅ SQL SERVER');
console.log(' - PUT /compteurs/:id');
console.log(' - POST /compteurs/ajouter-recup');
console.log(' - POST /compteurs/retirer-recup');
console.log(' - GET /equipes');
console.log(' - GET /equipes/:id');
console.log(' - GET /equipes/:id/membres');
console.log(' - GET /stats');
console.log(' - GET /historique');
console.log(' - GET /v2/compteurs ✅ API V2');
console.log(' - PUT /v2/compteurs/update ✅ API V2');
console.log(' - GET /getAllDetailedCounters ✅ SQL SERVER');
console.log('================================================');
});
server.on('error', (error) => {
console.error('\n❌ ERREUR SERVEUR:', error);
if (error.code === 'EADDRINUSE') {
console.error(`⚠️ Le port ${PORT} est déjà utilisé`);
}
});
setInterval(() => { }, 60000);
} catch (error) {
console.error('\n❌❌❌ ERREUR FATALE AU CHARGEMENT ❌❌❌');
console.error(error);
console.error(error.stack);
process.exit(1);
}