Files
GTARH/Backend/server.js
2025-12-02 17:57:33 +01:00

4263 lines
165 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
console.log('🚀 1. Démarrage du script...');
const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');
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
// ⭐ 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 || 3001;
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 MySQL
const dbConfig = {
host: process.env.DB_SERVER || '192.168.0.4',
user: process.env.DB_USER || 'wpuser',
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE || 'DemandeConge',
waitForConnections: true,
connectionLimit: 10
};
console.log('🔄 13. Test connexion MySQL...');
const pool = mysql.createPool(dbConfig);
// TEST DE CONNEXION IMMÉDIAT
pool.getConnection()
.then(conn => {
console.log('✅ 14. Connexion MySQL réussie');
conn.release();
})
.catch(err => {
console.error('❌ 14. ERREUR CONNEXION MYSQL:', err.message);
console.error(' Host:', dbConfig.host);
console.error(' User:', dbConfig.user);
console.error(' Database:', dbConfig.database);
});
console.log('✅ 15. Pool MySQL créé');
// 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;
}
}
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)
// ================================================
// ================================================
// GESTION DES SERVER-SENT EVENTS (SSE)
// ================================================
const sseClients = new Set();
// 🔌 ROUTE SSE POUR LE CALENDRIER
// ROUTE SSE POUR LE CALENDRIER RH
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}`);
notifyClients({
type: 'demande-validated',
demandeId: data.demandeId,
statut: data.statut,
typeConge: data.typeConge || data.type,
type: data.type || data.typeConge,
couleurHex: data.couleurHex || '#d946ef',
date: data.date,
periode: data.periode,
collaborateurId: data.collaborateurId,
timestamp: new Date().toISOString()
}, data.collaborateurId);
console.log(` ✅ SSE envoyé au collaborateur ${data.collaborateurId}`);
break;
// 🆕 AJOUTER CE CAS POUR LES ANNULATIONS
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'}`);
// Notifier les clients SSE
notifyClients({
type: 'demande-cancelled',
demandeId: data.demandeId,
statut: 'Annulée',
collaborateurId: data.collaborateurId,
annulateurRole: data.annulateurRole,
commentaire: data.commentaire,
timestamp: new Date().toISOString()
});
// Notifier spécifiquement le collaborateur concerné
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 {
// On récupère juste l'essentiel pour le sélecteur
const [users] = await pool.query(`
SELECT id, email, nom, prenom, role, service, Actif
FROM CollaborateurAD
WHERE Actif = 1 OR Actif IS NULL
ORDER BY nom, prenom
`);
res.json(users);
} 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;
// ✅ MODIFICATION: Ajouter le filtre Actif
const [users] = await pool.query(
`SELECT * FROM CollaborateurAD
WHERE email = ?
AND (Actif = 1 OR Actif IS NULL)`,
[userEmail]
);
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 /api/auth/login:', error);
res.status(500).json({
error: 'Erreur serveur inattendue',
details: error.message
});
}
});
// ================================================
// ROUTES DES DEMANDES DE CONGÉS
// ================================================
app.get('/demandes', authenticateToken, async (req, res) => {
try {
const { statut, dateDebut, dateFin, service } = req.query;
let query = `
SELECT dc.*,
CONCAT(ca.prenom, ' ', ca.nom) as nomEmploye,
ca.email as emailEmploye,
ca.Actif as employeActif,
s.Nom as service,
GROUP_CONCAT(DISTINCT tc.Nom SEPARATOR ', ') as typesConge,
CONCAT(v.prenom, ' ', v.nom) as nomValidateur
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id
WHERE 1=1
`;
const params = [];
if (statut) {
query += ' AND dc.Statut = ?';
params.push(statut);
}
if (dateDebut) {
query += ' AND dc.DateDebut >= ?';
params.push(dateDebut);
}
if (dateFin) {
query += ' AND dc.DateFin <= ?';
params.push(dateFin);
}
if (service) {
query += ' AND s.Id = ?';
params.push(service);
}
query += ' GROUP BY dc.Id ORDER BY dc.DateDemande DESC';
const [demandes] = await pool.query(query, params);
res.json(demandes);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/demandes/:id', authenticateToken, async (req, res) => {
try {
const [demandes] = await pool.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 = ?
`, [req.params.id]);
if (demandes.length === 0) {
return res.status(404).json({ error: 'Demande non trouvée' });
}
const [types] = await pool.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 = ?
`, [req.params.id]);
res.json({
...demandes[0],
typesConge: types
});
} catch (error) {
console.error('❌ Erreur détails demande:', error);
console.error(' Code:', error.code);
console.error(' SQL:', error.sqlMessage);
res.status(500).json({ error: error.message, details: error.sqlMessage });
}
});
app.post('/demandes', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { collaborateurId, dateDebut, dateFin, typesConge, commentaire } = req.body;
// ✅ Vérifier que le collaborateur est actif
const [collab] = await conn.query(
'SELECT Actif FROM CollaborateurAD WHERE id = ?',
[collaborateurId]
);
if (collab.length === 0 || collab[0].Actif === 0) {
await conn.rollback();
return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' });
}
const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
const [result] = await conn.query(
`INSERT INTO DemandeConge (CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours)
VALUES (?, ?, ?, 'En attente', ?, NOW(), ?)`,
[collaborateurId, dateDebut, dateFin, commentaire || '', totalJours]
);
const demandeId = result.insertId;
for (const type of typesConge) {
await conn.query(
'INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (?, ?, ?)',
[demandeId, type.typeId, type.nombreJours]
);
}
await conn.commit();
notifyClients({
type: 'demande-created',
demandeId: demandeId,
collaborateurId: collaborateurId,
timestamp: new Date().toISOString()
});
res.json({ id: demandeId, message: 'Demande créée avec succès' });
} catch (error) {
await conn.rollback();
res.status(500).json({ error: error.message });
} finally {
conn.release();
}
});
app.put('/demandes/:id', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { id } = req.params;
const { dateDebut, dateFin, typesConge, commentaire } = req.body;
const [demande] = await conn.query(
'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?',
[id]
);
if (demande.length === 0) {
await conn.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 conn.rollback();
return res.status(403).json({
error: 'Accès non autorisé',
details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande'
});
}
if (ancienStatut === 'Validée') {
console.log('⚠️ Modification d\'une demande validée - restauration des compteurs');
const [ancienTypes] = await conn.query(
'SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = ?',
[id]
);
for (const type of ancienTypes) {
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde + ?
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = YEAR(NOW())`,
[type.NombreJours, collaborateurId, type.TypeCongeId]
);
}
}
const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
const nouveauStatut = (ancienStatut === 'Validée' || ancienStatut === 'Refusée')
? 'En attente'
: ancienStatut;
await conn.query(
`UPDATE DemandeConge
SET DateDebut = ?,
DateFin = ?,
Commentaire = ?,
NombreJours = ?,
Statut = ?,
ValidateurADId = NULL,
DateValidation = NULL,
CommentaireValidation = NULL
WHERE Id = ?`,
[dateDebut, dateFin, commentaire || '', totalJours, nouveauStatut, id]
);
await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]);
for (const type of typesConge) {
await conn.query(
'INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (?, ?, ?)',
[id, type.typeId, type.nombreJours]
);
}
const actionDetails = ancienStatut === 'Validée'
? `Modification par ${req.user.role} - demande validée remise en attente`
: ancienStatut === 'Refusée'
? `Modification par ${req.user.role} - demande refusée remise en attente`
: `Modification par ${req.user.role}`;
await conn.query(
`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId)
VALUES (?, ?, ?, ?)`,
[req.user.id, 'Modification demande', actionDetails, id]
);
await conn.commit();
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()
});
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_UPDATED,
{
demandeId: parseInt(id),
collaborateurId: collaborateurId,
nouveauStatut: nouveauStatut,
ancienStatut: ancienStatut
}
);
if (nouveauStatut !== ancienStatut) {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId: collaborateurId }
);
}
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
const message = nouveauStatut !== ancienStatut
? `Demande modifiée avec succès et remise en attente (était: ${ancienStatut})`
: 'Demande modifiée avec succès';
res.json({
message,
nouveauStatut,
ancienStatut
});
console.log(`✅ Demande ${id} modifiée par ${req.user.role} - Ancien: ${ancienStatut}, Nouveau: ${nouveauStatut}`);
} catch (error) {
await conn.rollback();
console.error('Erreur modification demande:', error);
res.status(500).json({ error: error.message });
} finally {
conn.release();
}
});
app.delete('/demandes/:id', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { id } = req.params;
const [demande] = await conn.query(
'SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = ?',
[id]
);
if (demande.length === 0) {
await conn.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 conn.rollback();
return res.status(403).json({
error: 'Accès non autorisé',
details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent supprimer cette demande'
});
}
// ✅ AJOUT: Supprimer d'abord les entrées d'historique
await conn.query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = ?', [id]);
// Supprimer les types de congé
await conn.query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = ?', [id]);
// Supprimer la demande
await conn.query('DELETE FROM DemandeConge WHERE Id = ?', [id]);
await conn.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 conn.rollback();
console.error('❌ Erreur suppression demande:', error);
// ✅ AJOUT: Log plus détaillé de l'erreur
console.error(' Code erreur:', error.code);
console.error(' SQL Message:', error.sqlMessage);
res.status(500).json({ error: error.message });
} finally {
conn.release();
}
});
// ⭐ ROUTE VALIDATION AVEC WEBHOOKS
app.put('/demandes/:id/valider', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const { id } = req.params;
const { statut, commentaire } = req.body;
console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ===');
const [demandeInfo] = await conn.query(
`SELECT
dc.Id,
dc.CollaborateurADId,
dc.DateDebut,
dc.DateFin,
dc.NombreJours,
ca.prenom,
ca.nom,
ca.email as collaborateur_email,
ca.Actif,
GROUP_CONCAT(tc.Nom SEPARATOR ', ') 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 = ?
GROUP BY dc.Id
LIMIT 1`,
[id]
);
if (demandeInfo.length === 0) {
await conn.rollback();
return res.status(404).json({ error: 'Demande non trouvée' });
}
const demande = demandeInfo[0];
const collaborateurId = demande.CollaborateurADId;
await conn.query(
`UPDATE DemandeConge
SET Statut = ?, CommentaireValidation = ?, ValidateurADId = ?, DateValidation = NOW()
WHERE Id = ?`,
[statut, commentaire || '', req.user.id, id]
);
if (statut === 'Validée') {
const [types] = await conn.query(
'SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = ?',
[id]
);
for (const type of types) {
await conn.query(
`UPDATE CompteurConges
SET Solde = Solde - ?
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = YEAR(NOW())`,
[type.NombreJours, collaborateurId, type.TypeCongeId]
);
}
}
await conn.query(
`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId)
VALUES (?, ?, ?, ?)`,
[req.user.id, statut === 'Validée' ? 'Validation congé' : 'Refus congé', commentaire, id]
);
await conn.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 conn.rollback();
console.error('\n❌ ERREUR VALIDATION:', error);
res.status(500).json({ error: error.message });
} finally {
conn.release();
}
});
// ================================================
// ROUTES EMPLOYÉS
// ================================================
app.get('/employes', authenticateToken, async (req, res) => {
try {
const includeInactifs = req.query.include_inactifs === 'true';
const serviceId = req.query.service_id;
let query = `
SELECT
ca.id,
ca.nom,
ca.prenom,
CONCAT(ca.prenom, ' ', ca.nom) AS nomComplet,
ca.email,
ca.role,
ca.Actif,
ca.DateSortie,
ca.TypeContrat,
s.Nom AS service,
s.Id AS serviceId,
COALESCE(c.Nom, 'Sans campus') AS campus,
c.Id AS campusId,
cc1.Solde AS soldeCP,
cc2.Solde AS soldeRTT
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus c ON ca.CampusId = c.Id
LEFT JOIN CompteurConges cc1 ON ca.id = cc1.CollaborateurADId
AND cc1.TypeCongeId = 1
AND cc1.Annee = YEAR(NOW())
LEFT JOIN CompteurConges cc2 ON ca.id = cc2.CollaborateurADId
AND cc2.TypeCongeId = 2
AND cc2.Annee = YEAR(NOW())
WHERE 1=1
`;
const params = [];
if (!includeInactifs) {
query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)';
}
// 🆕 Ajouter le filtre service si fourni
if (serviceId && serviceId !== 'all') {
query += ' AND ca.ServiceId = ?';
params.push(serviceId);
}
query += ' ORDER BY c.Nom, s.Nom, ca.nom, ca.prenom';
const [employes] = await pool.query(query, params);
res.json(employes);
} catch (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 [collab] = await pool.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[collaborateur_id]
);
if (collab.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
await pool.query(
`UPDATE CollaborateurAD
SET Actif = FALSE, DateSortie = ?
WHERE id = ?`,
[dateSortie, collaborateur_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 [collab] = await pool.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[collaborateur_id]
);
if (collab.length === 0) {
return res.json({ success: false, message: 'Collaborateur non trouvé' });
}
await pool.query(
`UPDATE CollaborateurAD
SET Actif = TRUE, DateSortie = NULL
WHERE id = ?`,
[collaborateur_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 [types] = await pool.query(
'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom'
);
res.json(types);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/export/paie', authenticateToken, async (req, res) => {
try {
const { mois, annee } = req.query;
console.log(`📊 Export paie demandé : ${mois}/${annee}`);
// Date de début et fin du mois
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}`;
console.log(`📅 Période : ${premierJour} à ${dernierJourMois}`);
// Récupérer toutes les demandes validées qui chevauchent le mois
const [demandes] = await pool.query(`
SELECT
ca.id as collaborateurId,
CONCAT(ca.prenom, ' ', ca.nom) as employe,
ca.email,
s.Nom as service,
tc.Nom as typeConge,
dct.NombreJours,
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 (
(dc.DateDebut BETWEEN ? AND ?) OR
(dc.DateFin BETWEEN ? AND ?) OR
(dc.DateDebut <= ? AND dc.DateFin >= ?)
)
AND (ca.Actif = 1 OR ca.Actif IS NULL)
ORDER BY ca.nom, ca.prenom, dc.DateDebut
`, [premierJour, dernierJourMois, premierJour, dernierJourMois, premierJour, dernierJourMois]);
console.log(`📋 Nombre de demandes trouvées : ${demandes.length}`);
if (demandes.length > 0) {
console.log('🔍 Exemple de demande:', demandes[0]);
}
// Regrouper par employé
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: [] },
cp: { nb: 0, dates: [] },
aap: { nb: 0, dates: [] },
am: { nb: 0, dates: [] },
autres: []
});
}
const employe = employesMap.get(key);
// Formatter les dates
const dateDebut = new Date(row.DateDebut);
const dateFin = new Date(row.DateFin);
const formatDate = (date) => {
return String(date.getDate()).padStart(2, '0') + '/' +
String(date.getMonth() + 1).padStart(2, '0');
};
const dateDebutStr = formatDate(dateDebut);
const dateFinStr = formatDate(dateFin);
const periode = dateDebutStr === dateFinStr ? dateDebutStr : `${dateDebutStr}-${dateFinStr}`;
// ✅ CORRECTION : Convertir en nombre
const nombreJours = parseFloat(row.NombreJours);
// Catégoriser selon le type de congé
const typeConge = row.typeConge.toLowerCase();
console.log(` Type détecté: "${row.typeConge}" (${nombreJours}j)`);
if (typeConge.includes('rtt')) {
employe.rtt.nb += nombreJours; // ✅ nombreJours est maintenant un nombre
employe.rtt.dates.push(periode);
console.log(` ✅ Classé en RTT (total: ${employe.rtt.nb}j)`);
} else if (typeConge.includes('cp') || typeConge.includes('congé') || typeConge.includes('conge')) {
employe.cp.nb += nombreJours; // ✅ nombreJours est maintenant un nombre
employe.cp.dates.push(periode);
console.log(` ✅ Classé en CP (total: ${employe.cp.nb}j)`);
} else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') ||
typeConge.includes('enfant malade') || typeConge.includes('récup') ||
typeConge.includes('recuperation')) {
employe.aap.nb += nombreJours;
employe.aap.dates.push(periode);
console.log(` ✅ Classé en AAP (total: ${employe.aap.nb}j)`);
} else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) {
employe.am.nb += nombreJours;
employe.am.dates.push(periode);
console.log(` ✅ Classé en AM (total: ${employe.am.nb}j)`);
} else {
// Autres types
const existingAutre = employe.autres.find(a => a.type === row.typeConge);
if (existingAutre) {
existingAutre.nb += nombreJours;
existingAutre.dates.push(periode);
} else {
employe.autres.push({
type: row.typeConge,
nb: nombreJours,
dates: [periode]
});
}
console.log(` ✅ Classé en Autres (total: ${nombreJours}j)`);
}
});
// Formatter les dates (joindre avec " ; ")
const dataFormatted = 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(' ; ') },
cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; ') },
aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; ') },
am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; ') },
autres: emp.autres.map(a => ({
type: a.type,
nb: a.nb,
dates: a.dates.join(' ; ')
}))
}));
console.log(`${dataFormatted.length} collaborateurs dans le rapport`);
res.json(dataFormatted);
} catch (error) {
console.error('❌ Erreur export paie:', error);
res.status(500).json({ error: error.message });
}
});
// ========================================
// ROUTE COMPLÈTE : /reinitializeAllCounters
// ========================================
app.post('/reinitializeAllCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
console.log('🔄 Réinitialisation de tous les compteurs...');
const collaborateurs = await conn.query(`
SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role
FROM CollaborateurAD
WHERE actif = 1 OR actif IS NULL
`);
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 cpType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']);
const rttType = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']);
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);
// ✅ PROTECTION : Si NaN, forcer à 0
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(conn, collab.id, today);
acquisRTT = rttData.acquisition;
// ✅ PROTECTION : Si NaN, forcer à 0
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 deductionsCP = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dc.Statut != 'Refusé'
`, [collab.id, cpType[0].Id, currentYear]);
let totalConsomme = parseFloat(deductionsCP[0].totalConsomme || 0);
// ✅ PROTECTION
if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`);
totalConsomme = 0;
}
const compteurExisting = await conn.query(`
SELECT SoldeReporte FROM CompteurConges
WHERE CollaborateurADId = ?
AND TypeCongeId = ?
AND Annee = ?
`, [collab.id, cpType[0].Id, currentYear]);
let soldeReporte = compteurExisting.length > 0
? parseFloat(compteurExisting[0].SoldeReporte || 0)
: 0;
// ✅ PROTECTION
if (isNaN(soldeReporte) || !isFinite(soldeReporte)) {
console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`);
soldeReporte = 0;
}
let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme);
// ✅ PROTECTION FINALE
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 conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = NOW()
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [acquisCP, nouveauSolde, collab.id, cpType[0].Id, currentYear]);
} else {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, NOW())
`, [collab.id, cpType[0].Id, currentYear, acquisCP, nouveauSolde]);
}
// Créer CP N-1 si nécessaire
const cpN1 = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, cpType[0].Id, previousYear]);
if (cpN1.length === 0) {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, 0, 0, 0, NOW())
`, [collab.id, cpType[0].Id, previousYear]);
}
}
// ==========================================
// MISE À JOUR RTT N
// ==========================================
if (rttType.length > 0 && !isApprenti) {
const deductionsRTT = await conn.query(`
SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
FROM DeductionDetails dd
JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dc.Statut != 'Refusé'
`, [collab.id, rttType[0].Id, currentYear]);
let totalConsomme = parseFloat(deductionsRTT[0].totalConsomme || 0);
// ✅ PROTECTION
if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`);
totalConsomme = 0;
}
let nouveauSolde = Math.max(0, acquisRTT - totalConsomme);
// ✅ PROTECTION FINALE
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 compteurExisting = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, rttType[0].Id, currentYear]);
if (compteurExisting.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = NOW()
WHERE Id = ?
`, [acquisRTT, nouveauSolde, compteurExisting[0].Id]);
} else {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, NOW())
`, [collab.id, rttType[0].Id, currentYear, acquisRTT, nouveauSolde]);
}
// Créer RTT N-1 si nécessaire
const rttN1 = await conn.query(`
SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, rttType[0].Id, previousYear]);
if (rttN1.length === 0) {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, 0, 0, 0, NOW())
`, [collab.id, rttType[0].Id, previousYear]);
}
}
// Ajouter au résultat
results.push({
collaborateur: `${collab.prenom} ${collab.nom}`,
typecontrat: typeContrat,
cpacquis: acquisCP.toFixed(2),
rttacquis: acquisRTT.toFixed(2)
});
}
await conn.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 conn.rollback();
console.error('❌ Erreur réinitialisation:', error);
res.status(500).json({
success: false,
message: 'Erreur lors de la réinitialisation',
error: error.message
});
} finally {
conn.release();
}
});
// Route pour obtenir les informations d'un collaborateur
app.get('/employes/:id/info', authenticateToken, async (req, res) => {
try {
const [employe] = await pool.query(
`SELECT
id,
nom,
prenom,
email,
role,
TypeContrat,
Actif,
DateSortie,
ServiceId
FROM CollaborateurAD
WHERE id = ?`,
[req.params.id]
);
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 /api/compteurs pour inclure les infos sur les apprentis
app.get('/compteurs', authenticateToken, async (req, res) => {
try {
const includeInactifs = req.query.include_inactifs === 'true';
let query = `
SELECT
cc.Id as id,
cc.CollaborateurADId as collaborateurId,
cc.TypeCongeId as typeCongeId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
ca.Actif,
ca.role,
ca.TypeContrat,
s.Nom 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
`;
if (!includeInactifs) {
query += ' WHERE (ca.Actif = 1 OR ca.Actif IS NULL)';
}
query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom';
const [compteurs] = await pool.query(query);
// Ajouter l'indicateur apprenti à chaque compteur
const compteursAvecStatut = compteurs.map(c => ({
...c,
estApprenti: c.TypeContrat === 'Apprentissage' ||
c.role === 'Apprenti' ||
c.role?.toLowerCase().includes('apprenti')
}));
res.json(compteursAvecStatut);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/compteurs/:id', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) {
await conn.rollback();
conn.release();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { id } = req.params;
const { total, solde } = req.body;
// ✅ CONVERTIR EN NOMBRES
const totalNum = parseFloat(total);
const soldeNum = parseFloat(solde);
const [existing] = await conn.query('SELECT * FROM CompteurConges WHERE Id = ?', [id]);
if (existing.length === 0) {
await conn.rollback();
conn.release();
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 [typeInfo] = await conn.query(
'SELECT Nom FROM TypeConge WHERE Id = ?',
[typeCongeId]
);
const typeConge = typeInfo.length > 0 ? typeInfo[0].Nom : 'Inconnu';
// ⭐ MISE À JOUR AVEC TRANSACTION
await conn.query(
`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`,
[totalNum, soldeNum, id]
);
await conn.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 AMÉLIORÉ AVEC TOUTES LES INFOS
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 conn.rollback();
console.error('Erreur modification compteur:', error);
res.status(500).json({ error: error.message });
} finally {
conn.release();
}
});
app.get('/compteurs', authenticateToken, async (req, res) => {
try {
const { user_id } = req.query;
const includeInactifs = req.query.include_inactifs === 'true';
let query = `
SELECT
cc.Id as id,
cc.CollaborateurADId as collaborateurId,
cc.TypeCongeId as typeCongeId,
CONCAT(ca.prenom, ' ', ca.nom) AS employe,
ca.email,
ca.Actif,
ca.role,
ca.TypeContrat,
s.Nom 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
`;
const params = [];
// Filtre par utilisateur si fourni
if (user_id) {
query += ' AND cc.CollaborateurADId = ?';
params.push(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 [compteurs] = await pool.query(query, params);
// Ajouter l'indicateur apprenti
const compteursAvecStatut = compteurs.map(c => ({
...c,
estApprenti: c.TypeContrat === 'Apprentissage' ||
c.role === 'Apprenti' ||
c.role?.toLowerCase().includes('apprenti')
}));
res.json(compteursAvecStatut);
} catch (error) {
console.error('❌ Erreur récupération compteurs:', 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 conn = await pool.getConnection();
try {
await conn.beginTransaction();
if (!['Admin', 'RH'].includes(req.user.role)) {
await conn.rollback();
conn.release();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateurId, nombreJours, commentaire } = req.body;
if (!collaborateurId || !nombreJours || nombreJours <= 0) {
await conn.rollback();
conn.release();
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 [collaborateur] = await conn.query(
'SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = ?',
[collaborateurId]
);
if (collaborateur.length === 0) {
await conn.rollback();
conn.release();
return res.status(404).json({
success: false,
message: 'Collaborateur non trouvé'
});
}
if (collaborateur[0].Actif === 0) {
await conn.rollback();
conn.release();
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 [recupType] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
['Récupération']
);
if (recupType.length === 0) {
await conn.rollback();
conn.release();
return res.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 [compteurExisting] = await conn.query(
`SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, recupTypeId, currentYear]
);
if (compteurExisting.length > 0) {
// Mettre à jour le compteur existant
await conn.query(
`UPDATE CompteurConges
SET Total = Total + ?,
Solde = Solde + ?,
DerniereMiseAJour = NOW()
WHERE Id = ?`,
[nombreJours, nombreJours, compteurExisting[0].Id]
);
console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${(parseFloat(compteurExisting[0].Solde) + nombreJours).toFixed(2)}j)`);
} else {
// Créer un nouveau compteur
await conn.query(
`INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, NOW())`,
[collaborateurId, recupTypeId, currentYear, nombreJours, nombreJours]
);
console.log(`✅ Compteur Récupération créé: ${nombreJours}j`);
}
// Enregistrer dans l'historique
await conn.query(
`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (?, ?, ?, NOW())`,
[
req.user.id,
'Ajout récupération',
`Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`
]
);
await conn.commit();
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeConge: 'Récupération',
action: 'ajout',
nombreJours: nombreJours,
timestamp: new Date().toISOString()
}, collaborateurId);
// Envoyer webhook au serveur collaborateurs
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId: collaborateurId }
);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
// Envoyer un email de notification au collaborateur
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 dans votre solde de récupération.</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);
}
}
conn.release();
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 conn.rollback();
if (conn) conn.release();
console.error('❌ Erreur ajout récupération:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// ================================================
// ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION
// ================================================
app.post('/compteurs/retirer-recup', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
if (!['Admin', 'RH'].includes(req.user.role)) {
await conn.rollback();
conn.release();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateurId, nombreJours, commentaire } = req.body;
if (!collaborateurId || !nombreJours || nombreJours <= 0) {
await conn.rollback();
conn.release();
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 [collaborateur] = await conn.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[collaborateurId]
);
if (collaborateur.length === 0) {
await conn.rollback();
conn.release();
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 [recupType] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
['Récupération']
);
if (recupType.length === 0) {
await conn.rollback();
conn.release();
return res.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 [compteur] = await conn.query(
`SELECT Id, Total, Solde FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, recupTypeId, currentYear]
);
if (compteur.length === 0 || compteur[0].Solde < nombreJours) {
await conn.rollback();
conn.release();
return res.status(400).json({
success: false,
message: 'Solde de récupération insuffisant',
soldeActuel: compteur.length > 0 ? compteur[0].Solde : 0
});
}
// Retirer les jours
await conn.query(
`UPDATE CompteurConges
SET Total = GREATEST(0, Total - ?),
Solde = GREATEST(0, Solde - ?),
DerniereMiseAJour = NOW()
WHERE Id = ?`,
[nombreJours, nombreJours, compteur[0].Id]
);
console.log(`${nombreJours}j retirés du compteur Récupération`);
// Enregistrer dans l'historique
await conn.query(
`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (?, ?, ?, NOW())`,
[
req.user.id,
'Retrait récupération',
`Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`
]
);
await conn.commit();
// Notifier les clients SSE
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateurId,
typeConge: 'Récupération',
action: 'retrait',
nombreJours: nombreJours,
timestamp: new Date().toISOString()
}, collaborateurId);
// Envoyer webhook
try {
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{ collaborateurId: collaborateurId }
);
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
conn.release();
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 conn.rollback();
if (conn) conn.release();
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 [equipes] = await pool.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(
-- D'abord essayer ServiceAffectation
(SELECT GROUP_CONCAT(DISTINCT c1.Nom ORDER BY c1.Nom SEPARATOR ',')
FROM ServiceAffectation sa1
JOIN Campus c1 ON sa1.CampusId = c1.Id
WHERE sa1.ServiceId = s.Id),
-- Sinon utiliser les campus des collaborateurs
(SELECT GROUP_CONCAT(DISTINCT c2.Nom ORDER BY c2.Nom SEPARATOR ',')
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)),
'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 nombreMembres > 0 OR EXISTS (
SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id
)
ORDER BY s.Nom
`);
res.json(equipes);
} catch (error) {
console.error('❌ Erreur /api/equipes:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/equipes/:id', authenticateToken, async (req, res) => {
try {
const [equipes] = await pool.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(GROUP_CONCAT(DISTINCT c.Nom), '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
LEFT JOIN ServiceAffectation sa ON s.Id = sa.ServiceId
LEFT JOIN Campus c ON sa.CampusId = c.Id
WHERE s.Id = ?
GROUP BY s.Id, s.Nom
`, [req.params.id]);
if (equipes.length === 0) {
return res.status(404).json({ error: 'Service non trouvé' });
}
res.json(equipes[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/equipes/:id/membres', authenticateToken, async (req, res) => {
try {
const [membres] = await pool.query(`
SELECT
ca.id,
CONCAT(ca.prenom, ' ', ca.nom) as nom,
ca.email,
ca.role,
ca.Actif,
ca.DateSortie,
COALESCE(cc.Solde, 0) as soldeCP,
COALESCE(cc2.Solde, 0) as soldeRTT
FROM CollaborateurAD ca
LEFT JOIN CompteurConges cc ON ca.id = cc.CollaborateurADId
AND cc.TypeCongeId = 1
AND cc.Annee = YEAR(NOW())
LEFT JOIN CompteurConges cc2 ON ca.id = cc2.CollaborateurADId
AND cc2.TypeCongeId = 2
AND cc2.Annee = YEAR(NOW())
WHERE ca.ServiceId = ?
AND (ca.Actif = 1 OR ca.Actif IS NULL)
ORDER BY ca.nom, ca.prenom
`, [req.params.id]);
res.json(membres);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/stats', authenticateToken, async (req, res) => {
try {
const [stats] = await pool.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(NOW()) 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)
`);
res.json(stats[0]);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/historique', authenticateToken, async (req, res) => {
try {
const { dateDebut, dateFin, action } = req.query;
let query = `
SELECT
ha.Id,
CONCAT(ca.prenom, ' ', ca.nom) AS collaborateur,
ha.Action,
ha.Details,
ha.DateAction,
ha.AdresseIP,
ha.DemandeCongeId
FROM HistoriqueActions ha
JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id
WHERE 1=1
`;
const params = [];
if (dateDebut) {
query += ' AND ha.DateAction >= ?';
params.push(dateDebut);
}
if (dateFin) {
query += ' AND ha.DateAction <= ?';
params.push(dateFin);
}
if (action && action !== 'all') {
query += ' AND ha.Action = ?';
params.push(action);
}
query += ' ORDER BY ha.DateAction DESC LIMIT 500';
const [historique] = await pool.query(query, params);
res.json(historique);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================
// ROUTES COMPTES-RENDUS D'ACTIVITÉS (RH)
// ================================================
// GET - Stats globales des comptes-rendus
app.get('/compte-rendu/stats', authenticateToken, async (req, res) => {
const { annee, mois, service_id } = req.query;
console.log('📊 Route /api/compte-rendu/stats appelée');
console.log(' Année:', annee, 'Mois:', mois, 'Service:', service_id);
try {
let query = `
SELECT
COUNT(DISTINCT ca.id) as totalCollaborateurs,
COALESCE(SUM(CASE WHEN crm.Verrouille = 1 OR EXISTS(
SELECT 1 FROM CompteRenduActivites cra
WHERE cra.CollaborateurADId = ca.id
AND cra.Annee = ? AND cra.Mois = ?
) THEN 1 ELSE 0 END), 0) as mensuelsValides,
COALESCE(COUNT(DISTINCT ca.id) - SUM(CASE WHEN crm.Verrouille = 1 OR EXISTS(
SELECT 1 FROM CompteRenduActivites cra
WHERE cra.CollaborateurADId = ca.id
AND cra.Annee = ? AND cra.Mois = ?
) THEN 1 ELSE 0 END), 0) as mensuelsEnAttente
FROM CollaborateurAD ca
LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId
AND crm.Annee = ?
AND crm.Mois = ?
WHERE ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const params = [annee, mois, annee, mois, annee, mois];
// Ajouter le filtre service si fourni
if (service_id && service_id !== 'all') {
query += ' AND ca.ServiceId = ?';
params.push(service_id);
}
const [stats] = await pool.query(query, params);
console.log('✅ Stats récupérées:', stats[0]);
res.json({
totalCollaborateurs: stats[0].totalCollaborateurs || 0,
mensuelsValides: stats[0].mensuelsValides || 0,
mensuelsEnAttente: stats[0].mensuelsEnAttente || 0
});
} catch (error) {
console.error('❌ Erreur stats:', error);
console.error(' Message:', error.message);
console.error(' Code:', error.code);
// Retourner des stats par défaut en cas d'erreur
res.json({
totalCollaborateurs: 0,
mensuelsValides: 0,
mensuelsEnAttente: 0,
error: error.message
});
}
});
// Liste des collaborateurs en forfait jour avec leur statut de saisie
app.get('/compte-rendu/collaborateurs', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { annee, mois, service_id } = req.query;
let query = `
SELECT
ca.id,
ca.nom,
ca.prenom,
CONCAT(ca.prenom, ' ', ca.nom) AS nomComplet,
ca.email,
s.Nom AS service,
s.Id AS serviceId,
crm.Statut as statutMensuel,
crm.Verrouille,
crm.NbJoursTravailles,
crm.NbJoursNonRespectsReposQuotidien,
crm.NbJoursNonRespectsReposHebdo,
crm.DateValidation,
(SELECT COUNT(*) FROM CompteRenduActivites cra
WHERE cra.CollaborateurADId = ca.id
AND cra.Annee = ? AND cra.Mois = ?) as nbJoursSaisis
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId
AND crm.Annee = ? AND crm.Mois = ?
WHERE ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const params = [annee, mois, annee, mois];
// 🆕 Ajouter le filtre service si fourni
if (service_id && service_id !== 'all') {
query += ' AND ca.ServiceId = ?';
params.push(service_id);
}
query += ' ORDER BY ca.nom, ca.prenom';
const [collaborateurs] = await pool.query(query, params);
res.json(collaborateurs);
} catch (error) {
console.error('Erreur liste collaborateurs compte-rendu:', error);
res.status(500).json({ error: error.message });
}
});
// Verrouiller un compte-rendu (depuis RH)
app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateur_id, annee, mois } = req.body;
console.log('🔒 Verrouillage manuel par RH');
console.log(' Collaborateur:', collaborateur_id, 'Année:', annee, 'Mois:', mois);
// Calculer les stats
const [stats] = await pool.query(`
SELECT
COUNT(*) as nbJours,
SUM(CASE WHEN NOT ReposQuotidienRespect THEN 1 ELSE 0 END) as nbNonRespectQuotidien,
SUM(CASE WHEN NOT ReposHebdomadaireRespect THEN 1 ELSE 0 END) as nbNonRespectHebdo
FROM CompteRenduActivites
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ? AND JourTravaille = TRUE
`, [collaborateur_id, annee, mois]);
// Créer ou mettre à jour le mensuel
await pool.query(`
INSERT INTO CompteRenduMensuel
(CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo,
Statut, DateValidation, Verrouille)
VALUES (?, ?, ?, ?, ?, ?, 'Validé', NOW(), TRUE)
ON DUPLICATE KEY UPDATE
NbJoursTravailles = VALUES(NbJoursTravailles),
NbJoursNonRespectsReposQuotidien = VALUES(NbJoursNonRespectsReposQuotidien),
NbJoursNonRespectsReposHebdo = VALUES(NbJoursNonRespectsReposHebdo),
Statut = 'Validé',
DateValidation = NOW(),
Verrouille = TRUE,
DateModification = NOW()
`, [collaborateur_id, annee, mois, stats[0].nbJours, stats[0].nbNonRespectQuotidien, stats[0].nbNonRespectHebdo]);
// Enregistrer l'action dans l'historique
await pool.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (?, ?, ?, NOW())
`, [
req.user.id,
'Verrouillage compte-rendu',
`Verrouillage du mois ${mois}/${annee} pour le collaborateur ${collaborateur_id}`
]);
console.log(' ✅ Mois verrouillé avec succès');
res.json({
success: true,
message: 'Compte-rendu verrouillé avec succès'
});
} catch (error) {
console.error('❌ Erreur verrouillage compte-rendu:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Déverrouiller un compte-rendu (depuis RH)
app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { collaborateur_id, annee, mois } = req.body;
console.log('🔓 Déverrouillage manuel par RH');
console.log(' Collaborateur:', collaborateur_id, 'Année:', annee, 'Mois:', mois);
// Mettre à jour le statut
await pool.query(`
UPDATE CompteRenduMensuel
SET Verrouille = FALSE,
Statut = 'En cours',
DateModification = NOW()
WHERE CollaborateurADId = ? AND Annee = ? AND Mois = ?
`, [collaborateur_id, annee, mois]);
// Enregistrer l'action dans l'historique
await pool.query(`
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (?, ?, ?, NOW())
`, [
req.user.id,
'Déverrouillage compte-rendu',
`Déverrouillage du mois ${mois}/${annee} pour le collaborateur ${collaborateur_id}`
]);
console.log(' ✅ Mois déverrouillé avec succès');
res.json({
success: true,
message: 'Compte-rendu déverrouillé. Le collaborateur peut maintenant modifier ses saisies.'
});
} catch (error) {
console.error('❌ Erreur déverrouillage compte-rendu:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Export Excel de tous les comptes-rendus
app.get('/compte-rendu/export-excel', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { annee, mois, service_id } = req.query;
let query = `
SELECT
CONCAT(ca.prenom, ' ', ca.nom) AS Collaborateur,
ca.email AS Email,
s.Nom AS Service,
crm.NbJoursTravailles AS 'Jours travaillés',
crm.NbJoursNonRespectsReposQuotidien AS 'Non-respect repos quotidien',
crm.NbJoursNonRespectsReposHebdo AS 'Non-respect repos hebdo',
crm.Statut AS Statut,
crm.Verrouille AS Verrouillé,
DATE_FORMAT(crm.DateValidation, '%d/%m/%Y %H:%i') AS 'Date validation'
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId
AND crm.Annee = ? AND crm.Mois = ?
WHERE ca.TypeContrat = 'forfait_jour'
AND (ca.Actif = 1 OR ca.Actif IS NULL)
`;
const params = [annee, mois];
// 🆕 Ajouter le filtre service si fourni
if (service_id && service_id !== 'all') {
query += ' AND ca.ServiceId = ?';
params.push(service_id);
}
query += ' ORDER BY s.Nom, ca.nom, ca.prenom';
const [data] = await pool.query(query, params);
res.json(data);
} catch (error) {
console.error('Erreur export Excel:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/compte-rendu/services', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const [services] = await pool.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 nombreForfaitJour > 0
ORDER BY s.Nom
`);
res.json(services);
} catch (error) {
console.error('Erreur liste services:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/compte-rendu-activites', authenticateToken, async (req, res) => {
try {
const { user_id, annee, mois } = req.query;
console.log('📥 GET /api/compte-rendu-activites');
console.log(' User ID:', user_id, 'Année:', annee, 'Mois:', mois);
if (!user_id || !annee || !mois) {
return res.status(400).json({
success: false,
message: 'Paramètres manquants (user_id, annee, mois)'
});
}
// Récupérer les jours du compte-rendu
const [jours] = await pool.query(`
SELECT
Id,
CollaborateurADId,
JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos,
DateSaisie,
DateModification,
Annee,
Mois
FROM CompteRenduActivites
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
ORDER BY JourDate ASC
`, [user_id, annee, mois]);
console.log(`${jours.length} jours trouvés`);
// 🆕 SI DES JOURS EXISTENT, CRÉER/METTRE À JOUR AUTOMATIQUEMENT LE RÉCAPITULATIF MENSUEL VERROUILLÉ
let mensuelData = null;
if (jours.length > 0) {
// Calculer les statistiques
const nbJoursTravailles = jours.filter(j => j.JourTravaille).length;
const nbNonRespectQuotidien = jours.filter(j => !j.ReposQuotidienRespect && j.JourTravaille).length;
const nbNonRespectHebdo = jours.filter(j => !j.ReposHebdomadaireRespect && j.JourTravaille).length;
// Vérifier si le mensuel existe déjà
const [existingMensuel] = await pool.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
LIMIT 1
`, [user_id, annee, mois]);
if (existingMensuel.length === 0) {
// 🆕 CRÉER AUTOMATIQUEMENT UN MENSUEL VERROUILLÉ
await pool.query(`
INSERT INTO CompteRenduMensuel
(CollaborateurADId, Annee, Mois, NbJoursTravailles,
NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo,
Statut, DateValidation, Verrouille, DateModification)
VALUES (?, ?, ?, ?, ?, ?, 'Validé', NOW(), TRUE, NOW())
`, [user_id, annee, mois, nbJoursTravailles, nbNonRespectQuotidien, nbNonRespectHebdo]);
console.log(' ✅ Mensuel créé automatiquement et verrouillé');
mensuelData = {
CollaborateurADId: parseInt(user_id),
Annee: parseInt(annee),
Mois: parseInt(mois),
NbJoursTravailles: nbJoursTravailles,
NbJoursNonRespectsReposQuotidien: nbNonRespectQuotidien,
NbJoursNonRespectsReposHebdo: nbNonRespectHebdo,
Statut: 'Validé',
Verrouille: 1,
DateValidation: new Date(),
DateModification: new Date()
};
} else {
// 🆕 METTRE À JOUR LES STATS ET VERROUILLER AUTOMATIQUEMENT
await pool.query(`
UPDATE CompteRenduMensuel
SET NbJoursTravailles = ?,
NbJoursNonRespectsReposQuotidien = ?,
NbJoursNonRespectsReposHebdo = ?,
Statut = 'Validé',
Verrouille = TRUE,
DateValidation = NOW(),
DateModification = NOW()
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
`, [nbJoursTravailles, nbNonRespectQuotidien, nbNonRespectHebdo, user_id, annee, mois]);
console.log(' ✅ Mensuel mis à jour et verrouillé automatiquement');
mensuelData = {
...existingMensuel[0],
NbJoursTravailles: nbJoursTravailles,
NbJoursNonRespectsReposQuotidien: nbNonRespectQuotidien,
NbJoursNonRespectsReposHebdo: nbNonRespectHebdo,
Statut: 'Validé',
Verrouille: 1,
DateValidation: new Date(),
DateModification: new Date()
};
}
} else {
// Pas de jours saisis, vérifier quand même s'il existe un mensuel
const [existingMensuel] = await pool.query(`
SELECT * FROM CompteRenduMensuel
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
LIMIT 1
`, [user_id, annee, mois]);
mensuelData = existingMensuel.length > 0 ? existingMensuel[0] : null;
console.log(' Aucun jour saisi pour ce mois');
}
res.json({
success: true,
jours: jours,
mensuel: mensuelData,
collaborateurId: parseInt(user_id),
annee: parseInt(annee),
mois: parseInt(mois),
autoValidated: jours.length > 0 // Indicateur de validation automatique
});
} catch (error) {
console.error('❌ Erreur GET /api/compte-rendu-activites:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// ⭐ NOUVELLE ROUTE : Saisie de récupération par les RH pour un collaborateur
app.post('/compteurs/saisir-recup-collaborateur', authenticateToken, async (req, res) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
if (!['Admin', 'RH'].includes(req.user.role)) {
await conn.rollback();
conn.release();
return res.status(403).json({ error: 'Accès non autorisé' });
}
const {
collaborateur_id,
date,
periode_journee, // 'Matin', 'Après-midi', 'Journée entière'
commentaire
} = req.body;
console.log('📥 === SAISIE RÉCUP PAR RH ===');
console.log('Collaborateur:', collaborateur_id);
console.log('Date:', date);
console.log('Période:', periode_journee);
if (!collaborateur_id || !date || !periode_journee) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Données manquantes'
});
}
const dateObj = new Date(date);
// Calculer le nombre de jours selon la période
let nombre_heures;
switch (periode_journee) {
case 'Matin':
case 'Après-midi':
nombre_heures = 0.5;
break;
case 'Journée entière':
nombre_heures = 1;
break;
default:
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Période invalide'
});
}
// Vérifier que ce jour/période n'a pas déjà été saisi
const [existing] = await conn.query(`
SELECT dc.Id
FROM DemandeConge dc
JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
WHERE dc.CollaborateurADId = ?
AND dc.DateDebut = ?
AND tc.Nom = 'Récupération'
AND dct.PeriodeJournee = ?
`, [collaborateur_id, date, periode_journee]);
if (existing.length > 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: `Cette date (${periode_journee}) a déjà été déclarée`
});
}
// Récupérer infos collaborateur
const [userInfo] = await conn.query(
'SELECT prenom, nom, email FROM CollaborateurAD WHERE id = ?',
[collaborateur_id]
);
if (userInfo.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Collaborateur non trouvé'
});
}
const user = userInfo[0];
const userName = `${user.prenom} ${user.nom}`;
const dateFormatted = dateObj.toLocaleDateString('fr-FR');
// Récupérer le type Récupération
// 🎨 Récupérer le type Récupération avec sa couleur
const [recupType] = await conn.query(
'SELECT Id, CouleurHex FROM TypeConge WHERE Nom = ? LIMIT 1',
['Récupération']
);
if (recupType.length === 0) {
await conn.rollback();
conn.release();
return res.json({
success: false,
message: 'Type Récupération non trouvé. Veuillez créer ce type de congé.'
});
}
const recupTypeId = recupType[0].Id;
const couleurRecup = recupType[0].CouleurHex || '#d946ef'; // Fuchsia par défaut
const currentYear = dateObj.getFullYear();
console.log(`🎨 Type Récupération trouvé - ID: ${recupTypeId}, Couleur: ${couleurRecup}`);
// CRÉER LA DEMANDE (validée automatiquement avec ValidateurADId)
const commentaireFinal = commentaire || `🎯Jour travaillé (${periode_journee}) - Saisi par RH`;
const [result] = await conn.query(`
INSERT INTO DemandeConge
(CollaborateurADId, DateDebut, DateFin,
Statut, DateDemande, DateValidation, ValidateurADId, Commentaire, NombreJours)
VALUES (?, ?, ?, 'Validée', NOW(), NOW(), ?, ?, ?)
`, [collaborateur_id, date, date, req.user.id, commentaireFinal, nombre_heures]);
const demandeId = result.insertId;
// SAUVEGARDER DANS DemandeCongeType
await conn.query(`
INSERT INTO DemandeCongeType
(DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee)
VALUES (?, ?, ?, ?)
`, [demandeId, recupTypeId, nombre_heures, periode_journee]);
// ACCUMULER DANS LE COMPTEUR
const [compteur] = await conn.query(`
SELECT Id, Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateur_id, recupTypeId, currentYear]);
if (compteur.length > 0) {
await conn.query(`
UPDATE CompteurConges
SET Total = Total + ?,
Solde = Solde + ?,
DerniereMiseAJour = NOW()
WHERE Id = ?
`, [nombre_heures, nombre_heures, compteur[0].Id]);
console.log(`✅ Compteur mis à jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`);
} else {
await conn.query(`
INSERT INTO CompteurConges
(CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
VALUES (?, ?, ?, ?, ?, 0, NOW())
`, [collaborateur_id, recupTypeId, currentYear, nombre_heures, nombre_heures]);
console.log(`✅ Compteur créé: ${nombre_heures}j`);
}
// ENREGISTRER L'ACCUMULATION
await conn.query(`
INSERT INTO DeductionDetails
(DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
VALUES (?, ?, ?, 'Accum Récup', ?)
`, [demandeId, recupTypeId, currentYear, nombre_heures]);
// CRÉER NOTIFICATION POUR LE COLLABORATEUR
await conn.query(`
INSERT INTO Notifications
(CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu)
VALUES (?, 'Success', '✅ Récupération enregistrée', ?, ?, NOW(), 0)
`, [
collaborateur_id,
`Date ${dateFormatted} (${periode_journee}) enregistrée par RH : +${nombre_heures}j de récupération`,
demandeId
]);
// ENREGISTRER L'ACTION DANS L'HISTORIQUE
await conn.query(
`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
VALUES (?, ?, ?, NOW())`,
[
req.user.id,
'Saisie récupération RH',
`Ajout de ${nombre_heures}j (${periode_journee}) pour ${userName} - ${dateFormatted}`
]
);
await conn.commit();
// ⭐ ENVOYER WEBHOOK AU SERVEUR COLLABORATEURS
// ⭐ ENVOYER WEBHOOKS AU SERVEUR COLLABORATEURS
// ⭐ ENVOYER WEBHOOKS AU SERVEUR COLLABORATEURS
try {
// 1. Notifier la création de la demande validée (IMPORTANT pour le calendrier)
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.DEMANDE_VALIDATED,
{
demandeId: demandeId,
statut: 'Validée',
collaborateurId: collaborateur_id,
validateurId: req.user.id,
typeConge: 'Récupération',
type: 'Récupération',// ✅ Envoyer le nom exact
couleurHex: couleurRecup, // ⭐ AJOUTER LA COULEUR
date: date,
periode: periode_journee,
nombre_heures: nombre_heures
}
);
// 2. Notifier la mise à jour du compteur
await webhookManager.sendWebhook(
WEBHOOKS.COLLABORATEURS_URL,
EVENTS.COMPTEUR_UPDATED,
{
collaborateurId: collaborateur_id,
typeUpdate: 'recup_ajoutee',
date: date,
periode: periode_journee,
jours: nombre_heures
}
);
console.log('✅ Webhooks envoyés au serveur collaborateurs');
} catch (webhookError) {
console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
}
// Notifier les clients SSE (IMPORTANT pour rafraîchir le calendrier)
notifyClients({
type: 'demande-validated',
demandeId: demandeId,
statut: 'Validée',
collaborateurId: collaborateur_id,
typeConge: 'Récupération',
timestamp: new Date().toISOString()
}, collaborateur_id);
notifyClients({
type: 'compteur-updated',
collaborateurId: collaborateur_id,
typeConge: 'Récupération',
action: 'ajout_rh',
nombreJours: nombre_heures,
timestamp: new Date().toISOString()
}, collaborateur_id);
// ENVOYER EMAIL AU COLLABORATEUR
const accessToken = await getGraphToken();
if (accessToken && user.email) {
const fromEmail = 'noreply@ensup.eu';
const subject = '✅ Récupération enregistrée par RH';
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;">✅ Récupération ajoutée</h2>
</div>
<div style="background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb;">
<p style="font-size: 16px;">Bonjour <strong>${userName}</strong>,</p>
<p>Le service RH a enregistré une récupération pour vous.</p>
<div style="background-color: white; border-left: 4px solid #8b5cf6; padding: 15px; margin: 20px 0;">
<p><strong>Date :</strong> ${dateFormatted}</p>
<p><strong>Période :</strong> ${periode_journee}</p>
<p><strong>Jours ajoutés :</strong> ${nombre_heures} jour${nombre_heures > 1 ? 's' : ''}</p>
${commentaire ? `<p><strong>Commentaire :</strong> ${commentaire}</p>` : ''}
</div>
<p style="color: #6b7280;">Ces jours sont désormais disponibles dans votre solde de récupération.</p>
</div>
</div>
`;
try {
await sendMailGraph(accessToken, fromEmail, user.email, subject, body);
console.log('✅ Email de notification envoyé');
} catch (emailError) {
console.error('❌ Erreur envoi email:', emailError);
}
}
conn.release();
res.json({
success: true,
message: `Récupération enregistrée pour ${userName}`,
collaborateur: userName,
date: dateFormatted,
periode: periode_journee,
jours_ajoutes: nombre_heures,
demande_id: demandeId,
couleur: couleurRecup // 🎨 Retourner la couleur
});
} catch (error) {
await conn.rollback();
if (conn) conn.release();
console.error('❌ Erreur saisie récup RH:', error);
res.status(500).json({
success: false,
message: 'Erreur serveur',
error: error.message
});
}
});
// 🆕 BONUS : Route pour exporter le PDF d'un compte-rendu
app.get('/export-compte-rendu-pdf', authenticateToken, async (req, res) => {
try {
if (!['RH', 'Admin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
const { user_id, annee, mois } = req.query;
console.log('📄 Export PDF compte-rendu');
console.log(' User ID:', user_id, 'Année:', annee, 'Mois:', mois);
// Récupérer les infos du collaborateur
const [collaborateur] = await pool.query(`
SELECT
ca.id,
ca.nom,
ca.prenom,
ca.email,
s.Nom as service
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
WHERE ca.id = ?
`, [user_id]);
if (collaborateur.length === 0) {
return res.status(404).json({ error: 'Collaborateur non trouvé' });
}
const collab = collaborateur[0];
// Récupérer les jours
const [jours] = await pool.query(`
SELECT
JourDate,
JourTravaille,
ReposQuotidienRespect,
ReposHebdomadaireRespect,
CommentaireRepos
FROM CompteRenduActivites
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
ORDER BY JourDate ASC
`, [user_id, annee, mois]);
// Récupérer le mensuel
const [mensuel] = await pool.query(`
SELECT
NbJoursTravailles,
NbJoursNonRespectsReposQuotidien,
NbJoursNonRespectsReposHebdo,
Statut,
Verrouille,
DateValidation
FROM CompteRenduMensuel
WHERE CollaborateurADId = ?
AND Annee = ?
AND Mois = ?
LIMIT 1
`, [user_id, annee, mois]);
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
// GÉNÉRER LE PDF
const doc = new PDFDocument({
margin: 50,
size: 'A4'
});
// Headers pour le téléchargement
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=compte-rendu-${collab.nom}-${annee}-${String(mois).padStart(2, '0')}.pdf`);
// Pipe le PDF directement dans la réponse
doc.pipe(res);
// ===== EN-TÊTE =====
doc.fontSize(20)
.fillColor('#0ea5e9')
.text('Compte-rendu d\'activités', { align: 'center' })
.moveDown(0.5);
doc.fontSize(14)
.fillColor('#6b7280')
.text(`${monthNames[parseInt(mois) - 1]} ${annee}`, { align: 'center' })
.moveDown(2);
// ===== INFORMATIONS COLLABORATEUR =====
doc.fontSize(12)
.fillColor('#000000')
.text('Collaborateur', { underline: true })
.moveDown(0.3);
doc.fontSize(10)
.fillColor('#374151')
.text(`Nom: ${collab.prenom} ${collab.nom}`)
.text(`Email: ${collab.email}`)
.text(`Service: ${collab.service || 'Non défini'}`)
.moveDown(1.5);
// ===== RÉSUMÉ MENSUEL =====
if (mensuel.length > 0) {
const m = mensuel[0];
doc.fontSize(12)
.fillColor('#000000')
.text('Résumé du mois', { underline: true })
.moveDown(0.3);
// Rectangle de fond pour les stats
const y = doc.y;
doc.rect(50, y, 495, 80)
.fillAndStroke('#f0f9ff', '#0ea5e9');
doc.fillColor('#000000')
.fontSize(10)
.text(`Jours travaillés: ${m.NbJoursTravailles || 0}`, 60, y + 10)
.text(`Non-respect repos quotidien: ${m.NbJoursNonRespectsReposQuotidien || 0}`, 60, y + 30)
.text(`Non-respect repos hebdomadaire: ${m.NbJoursNonRespectsReposHebdo || 0}`, 60, y + 50);
const statutText = m.Verrouille ? 'Verrouillé' : 'Ouvert';
doc.text(`Statut: ${statutText}`, 300, y + 10);
if (m.DateValidation) {
const dateValidation = new Date(m.DateValidation).toLocaleDateString('fr-FR');
doc.text(`Date validation: ${dateValidation}`, 300, y + 30);
}
doc.y = y + 90;
doc.moveDown(1);
}
// ===== DÉTAIL DES JOURS =====
if (jours.length > 0) {
doc.fontSize(12)
.fillColor('#000000')
.text('Détail des jours', { underline: true })
.moveDown(0.5);
// ✅ DÉFINIR LES POSITIONS DES COLONNES
const cols = {
date: 60,
travaille: 150,
repos: 285, // Centre de la colonne "Respect des repos"
commentaire: 420
};
// En-têtes du tableau (bien alignés)
const headerY = doc.y;
doc.fontSize(9)
.fillColor('#6b7280');
doc.text('Date', cols.date, headerY, { width: 80, continued: false });
doc.text('Travaillé', cols.travaille - 10, headerY, { width: 90, align: 'center', continued: false });
doc.text('Respect des repos', cols.repos - 60, headerY, { width: 120, align: 'center', continued: false });
doc.text('Commentaire', cols.commentaire, headerY, { width: 125, continued: false });
doc.moveDown(0.3);
doc.moveTo(50, doc.y)
.lineTo(545, doc.y)
.stroke('#d1d5db');
doc.moveDown(0.3);
// 🎨 Fonction pour dessiner un cercle avec coche verte
const drawGreenCheck = (x, y) => {
// Cercle vert
doc.circle(x, y, 8)
.fillAndStroke('#22c55e', '#16a34a');
// Coche blanche
doc.strokeColor('#ffffff')
.lineWidth(2)
.moveTo(x - 4, y)
.lineTo(x - 1, y + 3)
.lineTo(x + 4, y - 4)
.stroke();
// Remettre la couleur de trait par défaut
doc.strokeColor('#000000').lineWidth(1);
};
// 🎨 Fonction pour dessiner un cercle avec X rouge
const drawRedX = (x, y) => {
// Cercle rouge
doc.circle(x, y, 8)
.fillAndStroke('#ef4444', '#dc2626');
// X blanc
doc.strokeColor('#ffffff')
.lineWidth(2)
.moveTo(x - 4, y - 4)
.lineTo(x + 4, y + 4)
.stroke();
doc.moveTo(x + 4, y - 4)
.lineTo(x - 4, y + 4)
.stroke();
// Remettre la couleur de trait par défaut
doc.strokeColor('#000000').lineWidth(1);
};
// Lignes du tableau
jours.forEach((jour, index) => {
if (doc.y > 700) {
doc.addPage();
doc.y = 50;
}
const jourDate = new Date(jour.JourDate).toLocaleDateString('fr-FR');
const travaille = jour.JourTravaille ? '✓' : '✗';
// Vérifier si tout est respecté
const toutRespect = jour.ReposQuotidienRespect && jour.ReposHebdomadaireRespect;
const commentaire = jour.CommentaireRepos || '-';
const rowY = doc.y;
// Alterner les couleurs de fond
if (index % 2 === 0) {
doc.rect(50, rowY - 2, 495, 20)
.fill('#f9fafb');
}
// Date
doc.fontSize(8)
.fillColor('#000000')
.text(jourDate, cols.date, rowY + 3, { width: 80, continued: false });
// Travaillé (centré)
doc.text(travaille, cols.travaille - 10, rowY + 3, { width: 90, align: 'center', continued: false });
// 🎨 AFFICHER LE CERCLE AVEC COCHE OU X (centré)
if (toutRespect) {
// ✅ Cercle vert avec coche blanche
drawGreenCheck(cols.repos, rowY + 7);
} else {
// ❌ Cercle rouge avec X blanc
drawRedX(cols.repos, rowY + 7);
// Ajouter les détails en petit texte rouge
const details = [];
if (!jour.ReposQuotidienRespect) details.push('Q');
if (!jour.ReposHebdomadaireRespect) details.push('H');
doc.fontSize(6)
.fillColor('#ef4444')
.text(details.join(' '), cols.repos + 12, rowY + 4, { width: 50, continued: false });
}
// Commentaire
doc.fillColor('#000000')
.fontSize(7)
.text(commentaire.substring(0, 30), cols.commentaire, rowY + 3, { width: 125, continued: false });
doc.moveDown(0.8);
});
} else {
doc.fontSize(10)
.fillColor('#6b7280')
.text('Aucune saisie pour ce mois', { align: 'center' })
.moveDown(1);
}
// ===== PIED DE PAGE =====
doc.fontSize(8)
.fillColor('#9ca3af')
.text(
`Document généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}`,
50,
750,
{ align: 'center' }
);
// Finaliser le PDF
doc.end();
console.log('✅ PDF généré avec succès');
} catch (error) {
console.error('❌ Erreur export PDF:', error);
// Si le document n'a pas encore été envoyé
if (!res.headersSent) {
res.status(500).json({ error: error.message });
}
}
});
app.post('/v2/compteurs/update', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateurId, typeConge, annee, nouveauTotal } = req.body;
// Sécurité RH
if (!['RH', 'Admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Accès refusé' });
}
// Conversion type CP/RTT → Id
const [typeRow] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
[typeConge === 'CP' ? 'Congé payé' : 'RTT']
);
if (typeRow.length === 0) {
return res.status(400).json({ error: 'Type de congé inconnu' });
}
const typeCongeId = typeRow[0].Id;
// Récupération compteur existant
const [compteur] = await conn.query(`
SELECT Id, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, annee]);
if (compteur.length === 0) {
return res.status(404).json({ error: 'Compteur non trouvé' });
}
const compteurId = compteur[0].Id;
// Mise à jour propre V2
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = NOW()
WHERE Id = ?
`, [nouveauTotal, nouveauTotal, compteurId]);
// Webhook SSE → pour que le collaborateur voit la mise à jour
notifyCollabClients({
type: 'compteur-updated',
collaborateurId,
typeConge,
annee,
nouveauTotal,
timestamp: new Date().toISOString()
}, collaborateurId);
res.json({ success: true, message: "Compteur mis à jour (V2)" });
} catch (error) {
console.error('Erreur update compteur V2:', error);
res.status(500).json({ error: 'Erreur serveur' });
} finally {
conn.release();
}
});
// ============================================================
// ENDPOINTS API V2 - REQUÊTES CORRIGÉES
// Ajustées selon la structure réelle de la base DemandeConge
// ============================================================
app.post('/v2/compteurs/update', async (req, res) => {
const conn = await pool.getConnection();
try {
const { collaborateurId, typeConge, annee, nouveauTotal } = req.body;
// Sécurité RH
if (!['RH', 'Admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Accès refusé' });
}
// Conversion type CP/RTT → Id
const [typeRow] = await conn.query(
'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1',
[typeConge === 'CP' ? 'Congé payé' : 'RTT']
);
if (typeRow.length === 0) {
return res.status(400).json({ error: 'Type de congé inconnu' });
}
const typeCongeId = typeRow[0].Id;
// Récupération compteur existant
const [compteur] = await conn.query(`
SELECT Id, Total, Solde, SoldeReporte
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collaborateurId, typeCongeId, annee]);
if (compteur.length === 0) {
return res.status(404).json({ error: 'Compteur non trouvé' });
}
const compteurId = compteur[0].Id;
// Mise à jour propre V2
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = NOW()
WHERE Id = ?
`, [nouveauTotal, nouveauTotal, compteurId]);
// Webhook SSE → pour que le collaborateur voit la mise à jour
notifyCollabClients({
type: 'compteur-updated',
collaborateurId,
typeConge,
annee,
nouveauTotal,
timestamp: new Date().toISOString()
}, collaborateurId);
res.json({ success: true, message: "Compteur mis à jour (V2)" });
} catch (error) {
console.error('Erreur update compteur V2:', error);
res.status(500).json({ error: 'Erreur serveur' });
} finally {
conn.release();
}
});
app.get('/v2/compteurs', async (req, res) => {
const conn = await pool.getConnection();
try {
// ============================================================
// CORRECTIONS APPORTÉES :
// - c.Nom → c.nom (minuscule dans CollaborateurAD)
// - c.Prenom → c.prenom (minuscule)
// - c.Email → c.email (minuscule)
// - c.Service → c.service (minuscule)
// - c.Id → c.id (minuscule)
// - c.Role → c.role (minuscule)
// - c.EstApprenti → SUPPRIMÉ (n'existe pas dans la table)
// ============================================================
const [rows] = await conn.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
ORDER BY c.nom ASC, cc.Annee DESC
`);
return res.json(rows);
} catch (error) {
console.error("❌ Erreur GET compteurs V2 :", error);
return res.status(500).json({ message: "Erreur serveur V2" });
} finally {
conn.release();
}
});
app.put('/v2/compteurs/update', async (req, res) => {
const conn = await pool.getConnection();
try {
const {
collaborateurId,
typeConge,
annee,
nouveauTotal,
nouveauSolde,
source
} = req.body;
if (!collaborateurId || !typeConge || !annee) {
return res.status(400).json({ message: "Champs manquants" });
}
// Trouver l'ID du type de congé
// Note: Utiliser = au lieu de LIKE pour plus de précision
const [typeRow] = await conn.query(
"SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1",
[typeConge === 'CP' ? 'Congé payé' : 'RTT']
);
if (typeRow.length === 0) {
return res.status(404).json({ message: "Type de congé introuvable" });
}
const typeCongeId = typeRow[0].Id;
// Trouver compteur
const [comp] = await conn.query(
`SELECT Id FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`,
[collaborateurId, typeCongeId, annee]
);
if (comp.length === 0) {
return res.status(404).json({ message: "Compteur introuvable" });
}
// Maj compteur
await conn.query(`
UPDATE CompteurConges
SET Total = ?, Solde = ?, DerniereMiseAJour = NOW()
WHERE Id = ?
`, [nouveauTotal, nouveauSolde, comp[0].Id]);
res.json({ success: true, message: "Compteur mis à jour (V2)" });
} catch (err) {
console.error("Erreur update compteur V2:", err);
res.status(500).json({ message: "Erreur serveur" });
} finally {
conn.release();
}
});
app.post('/v2/compteurs/reinitialiser', async (req, res) => {
const conn = await pool.getConnection();
try {
const { dateReference, collaborateurId } = req.body;
if (!dateReference) {
return res.status(400).json({ message: "dateReference manquante" });
}
// Appel à ta fonction métier V2 (acquisition + arrêté)
await recalculerTousLesCompteursV2(conn, dateReference, collaborateurId);
res.json({ message: "Réinitialisation V2 effectuée" });
} catch (err) {
console.error("Erreur réinitialisation V2:", err);
res.status(500).json({ message: "Erreur serveur" });
} finally {
conn.release();
}
});
app.post('/v2/compteurs/initialiser-tous-manuel', async (req, res) => {
const conn = await pool.getConnection();
try {
const {
anneeActuelle,
cpActuel,
rttActuel,
anneePrecedente,
cpPrecedent,
rttPrecedent
} = req.body;
if (!anneeActuelle || cpActuel == null)
return res.status(400).json({ message: "Champs manquants" });
const result = await initialiserCompteursManuelV2(
conn,
anneeActuelle,
cpActuel,
rttActuel,
anneePrecedente,
cpPrecedent,
rttPrecedent
);
res.json(result);
} catch (err) {
console.error("Erreur init manuelle V2:", err);
res.status(500).json({ message: "Erreur serveur" });
} finally {
conn.release();
}
});
// ============================================
// CORRECTION : Gérer les dates d'entrée nulles
// ============================================
/**
* 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) { // ✅ AJOUT : Vérifier que dateEntree n'est pas null
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// ✅ AJOUT : Vérifier que la date est valide
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(conn, collaborateurId, dateReference = new Date()) {
const d = new Date(dateReference);
d.setHours(0, 0, 0, 0);
const annee = d.getFullYear();
// 1. Récupérer les infos du collaborateur
const collabInfo = await conn.query(
`SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = ?`,
[collaborateurId]
);
if (collabInfo.length === 0) {
throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
}
const typeContrat = collabInfo[0].TypeContrat || '37h';
const dateEntree = collabInfo[0].DateEntree;
const isApprenti = collabInfo[0].role === 'Apprenti';
// 2. Apprentis : pas de RTT
if (isApprenti) {
return {
acquisition: 0,
moisTravailles: 0,
config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
typeContrat: typeContrat
};
}
// 3. Récupérer la configuration RTT
const config = await getConfigurationRTT(conn, 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) { // ✅ AJOUT : Vérifier que dateEntree n'est pas null
const entree = new Date(dateEntree);
entree.setHours(0, 0, 0, 0);
// ✅ AJOUT : Vérifier que la date est valide
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);
// ✅ AJOUT : Vérifier que les dates sont valides
if (isNaN(b1.getTime()) || isNaN(b2.getTime())) {
console.error('❌ Date invalide dans calculerAcquisitionFormuleExcel');
return 0;
}
// ✅ AJOUT : 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(conn, annee, typeContrat = '37h') {
try {
// Chercher en base de données
const config = await conn.query(
`SELECT JoursAnnuels, AcquisitionMensuelle
FROM ConfigurationRTT
WHERE Annee = ? AND TypeContrat = ?
LIMIT 1`,
[annee, typeContrat]
);
if (config.length > 0) {
return {
joursAnnuels: parseFloat(config[0].JoursAnnuels),
acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle)
};
}
// 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
};
}
// 📊 ROUTE POUR L'ESPACE RH - Tous les compteurs détaillés
app.get('/getAllDetailedCounters', async (req, res) => {
const conn = await pool.getConnection();
try {
console.log('📊 Récupération de TOUS les compteurs détaillés (Version Fixée V2 + V1)');
// Récupérer tous les collaborateurs actifs
const [collaborateurs] = await conn.query(`
SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree,
s.Nom as service
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
`);
const resultats = [];
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
// Pré-charger les TypeConge Id
const [[cpType]] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Congé payé' LIMIT 1`);
const [[rttType]] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'RTT' LIMIT 1`);
const [[recupType]] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = 'Récupération' LIMIT 1`);
for (const collab of collaborateurs) {
//
// -----------------------------------------------------
// 1⃣ CONGÉS PAYÉS — ANNÉE N (V2 si existant, sinon V1)
// -----------------------------------------------------
//
let [cpN] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, cpType.Id, currentYear]);
let cpTotalN, cpSoldeN;
if (cpN.length > 0) {
// Utiliser données V2
cpTotalN = parseFloat(cpN[0].Total);
cpSoldeN = parseFloat(cpN[0].Solde);
} else {
// Fallback ancien calcul
cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree);
const [[consomme]] = await conn.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé')
AND dc.Statut != 'Refusé'
`, [collab.id, cpType.Id, currentYear]);
cpSoldeN = Math.max(0, cpTotalN - consomme.total);
}
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service || 'Non assigné',
typeConge: "Congé payé",
annee: currentYear,
total: parseFloat(cpTotalN.toFixed(2)),
solde: parseFloat(cpSoldeN.toFixed(2)),
consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)),
role: collab.role,
typeContrat: collab.TypeContrat
});
//
// -----------------------------------------------------
// 2⃣ CP N-1 (toujours basé sur CompteurConges)
// -----------------------------------------------------
//
let [cpN1] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, cpType.Id, previousYear]);
if (cpN1.length > 0 && cpN1[0].Solde > 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 || 'Non assigné',
typeConge: "Congé payé",
annee: previousYear,
total,
solde,
consomme: total - solde,
role: collab.role,
typeContrat: collab.TypeContrat
});
}
//
// -----------------------------------------------------
// 3⃣ RTT — ANNÉE N (V2 si existant)
// -----------------------------------------------------
//
if (collab.role !== 'Apprenti') {
let [rttN] = await conn.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
`, [collab.id, rttType.Id, currentYear]);
let rttTotalN, rttSoldeN;
if (rttN.length > 0) {
rttTotalN = parseFloat(rttN[0].Total);
rttSoldeN = parseFloat(rttN[0].Solde);
} else {
// Fallback ancien calcul
const rtt = await calculerAcquisitionRTT(conn, collab.id, new Date());
const [[consommeRTT]] = await conn.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée')
AND dc.Statut != 'Refusé'
`, [collab.id, rttType.Id, currentYear]);
rttTotalN = rtt.acquisition;
rttSoldeN = Math.max(0, rttTotalN - consommeRTT.total);
}
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service || 'Non assigné',
typeConge: "RTT",
annee: currentYear,
total: parseFloat(rttTotalN.toFixed(2)),
solde: parseFloat(rttSoldeN.toFixed(2)),
consomme: parseFloat((rttTotalN - rttSoldeN).toFixed(2)),
role: collab.role,
typeContrat: collab.TypeContrat
});
}
//
// -----------------------------------------------------
// 4⃣ RÉCUP — ANNÉE N
// -----------------------------------------------------
//
const [[accum]] = await conn.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction IN ('Accum. Récup', 'Accum. Recup')
`, [collab.id, recupType.Id, currentYear]);
const [[consomm]] = await conn.query(`
SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = ?
AND dd.TypeCongeId = ?
AND dd.Annee = ?
AND dd.TypeDeduction IN ('Récup Dosée', 'Recup Dosee')
`, [collab.id, recupType.Id, currentYear]);
const recupTotal = parseFloat(accum.total);
const recupCons = parseFloat(consomm.total);
const recupSolde = Math.max(0, recupTotal - recupCons);
if (recupTotal + recupCons > 0) {
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service || 'Non assigné',
typeConge: "Récupération",
annee: currentYear,
total: recupTotal,
solde: recupSolde,
consomme: recupCons,
role: collab.role,
typeContrat: collab.TypeContrat
});
}
}
conn.release();
res.json(resultats);
} catch (err) {
conn.release();
console.error("❌ Erreur GET ALL:", err);
res.status(500).json({ error: err.message });
}
});
// ✅ ROUTE STATS MANQUANTE
app.get('/api/stats', authenticateToken, async (req, res) => {
try {
console.log('📊 GET /api/stats appelé par', req.user.email);
// Stats simples pour test
const stats = {
totalEmployes: 0,
demandesEnAttente: 0,
demandesValidees: 0,
timestamp: new Date().toISOString()
};
// TODO: Requêtes SQL réelles ici
res.json(stats);
} catch (error) {
console.error('Erreur /api/stats:', error);
res.status(500).json({ error: 'Erreur stats' });
}
});
// ================================================
// DÉMARRAGE DU SERVEUR
// ================================================
console.log('✅ 99. Toutes les routes définies, démarrage du serveur...');
const server = app.listen(PORT, () => {
console.log('\n================================================');
console.log(`✅ SERVEUR RH DÉMARRÉ sur http://localhost:${PORT}`);
console.log('🔔 Server-Sent Events activés sur /api/events');
console.log('🔗 WEBHOOKS configurés:');
console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`);
console.log(` - Route webhook receiver: POST /api/webhook/receive`);
console.log('');
console.log('📋 Routes disponibles:');
console.log(' - GET /api/events (SSE)');
console.log(' - POST /api/webhook/receive');
console.log(' - POST /api/auth/login ✅ AVEC FILTRE ACTIF');
console.log(' - GET /api/demandes');
console.log(' - GET /api/demandes/:id');
console.log(' - POST /api/demandes');
console.log(' - PUT /api/demandes/:id');
console.log(' - DELETE /api/demandes/:id');
console.log(' - PUT /api/demandes/:id/valider');
console.log(' - GET /api/employes?include_inactifs=true ✅ MODIFIÉ');
console.log(' - POST /api/employes/desactiver 🆕 NOUVEAU');
console.log(' - POST /api/employes/reactiver 🆕 NOUVEAU');
console.log(' - GET /api/types-conge');
console.log(' - GET /api/export/paie');
console.log(' - GET /api/compteurs?include_inactifs=true ✅ MODIFIÉ');
console.log(' - POST /api/compteurs/reinitialiser');
console.log(' - POST /api/compteurs/initialiser-manuel');
console.log(' - POST /api/compteurs/initialiser-tous-manuel ✅ MODIFIÉ');
console.log(' - PUT /api/compteurs/:id');
console.log(' - POST /api/compteurs/ajouter-recup 🆕 NOUVEAU');
console.log(' - POST /api/compteurs/retirer-recup 🆕 NOUVEAU');
console.log(' - GET /api/equipes ✅ MODIFIÉ');
console.log(' - GET /api/equipes/:id ✅ MODIFIÉ');
console.log(' - GET /api/equipes/:id/membres ✅ MODIFIÉ');
console.log(' - GET /api/stats ✅ MODIFIÉ');
console.log(' - GET /api/historique');
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);
}