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'
? `
✅ Demande approuvée
Bonjour ${collaborateurNom},
Votre demande de congé a été approuvée par ${validateurNom}.
Type : ${demande.TypeConge}
Période : ${datesPeriode}
Durée : ${demande.NombreJours} jour(s)
${commentaire ? `
Commentaire : ${commentaire}
` : ''}
`
: `
❌ Demande refusée
Bonjour ${collaborateurNom},
Votre demande de congé a été refusée par ${validateurNom}.
Type : ${demande.TypeConge}
Période : ${datesPeriode}
Durée : ${demande.NombreJours} jour(s)
${commentaire ? `
Motif du refus : ${commentaire}
` : ''}
`;
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 = `
✅ Jours de récupération ajoutés
Bonjour ${collab.prenom} ${collab.nom},
Des jours de récupération ont été ajoutés à votre compteur.
Jours ajoutés : ${nombreJours} jour${nombreJours > 1 ? 's' : ''}
${commentaire ? `
Motif : ${commentaire}
` : ''}
Année : ${currentYear}
Ces jours sont désormais disponibles dans votre solde de récupération.
`;
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 = `
✅ Récupération ajoutée
Bonjour ${userName},
Le service RH a enregistré une récupération pour vous.
Date : ${dateFormatted}
Période : ${periode_journee}
Jours ajoutés : ${nombre_heures} jour${nombre_heures > 1 ? 's' : ''}
${commentaire ? `
Commentaire : ${commentaire}
` : ''}
Ces jours sont désormais disponibles dans votre solde de récupération.
`;
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);
}