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