1 Commits

Author SHA1 Message Date
oimer 762f9cfd4a GTARH_Modif_anno27 2026-05-28 11:11:52 +02:00
7 changed files with 336 additions and 989 deletions
+273 -387
View File
@@ -1,4 +1,4 @@

const express = require('express');
const cors = require('cors');
@@ -943,23 +943,7 @@ try {
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';
@@ -2473,82 +2457,6 @@ WHERE CollaborateurADId = @userId
}
});
// ================================================
// 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
@@ -5597,294 +5505,297 @@ app.get('/demandes/check-doublon', authenticateToken, async (req, res) => {
});
// ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters
app.get('/getAllDetailedCounters', async (req, res) => {
try {
console.log('📊 Récupération de TOUS les compteurs détaillés');
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
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é'");
// ── 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;
let currentYear = currentYearSys; // CP N (2026 ou 2027 après bascule)
let previousYear = currentYearSys - 1; // CP N-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 (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
AND SoldeReporte > 0
`);
if (checkBascule.recordset.length > 0) {
currentYear = currentYearSys + 1; // 2027
previousYear = currentYearSys; // 2026
console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
}
}
// RTT et Récup : toujours l'année civile système (jamais basculent)
const anneeRTT = currentYearSys; // 2026
const anneeRecup = currentYearSys; // 2026
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')
`);
if (checkBascule.recordset.length > 0) {
currentYear = currentYearSys + 1; // 2027
previousYear = currentYearSys; // 2026
console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
}
}
const typeCongeMap = {};
typesCongeResult.recordset.forEach(tc => {
typeCongeMap[tc.Nom] = tc.Id;
});
const anneeRTT = currentYearSys;
const anneeRecup = currentYearSys;
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
`);
console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
const collaborateurs = collaborateursResult.recordset;
const resultats = [];
const typesCongeResult = await pool.request().query(`
SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Récupération')
`);
console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
const typeCongeMap = {};
typesCongeResult.recordset.forEach(tc => {
typeCongeMap[tc.Nom] = tc.Id;
});
for (const collab of collaborateurs) {
const campusNom = collab.campusNom;
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
`);
// ==================== 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
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const collaborateurs = collaborateursResult.recordset;
const resultats = [];
const cpN = cpNResult.recordset;
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(`
let cpTotalN, cpSoldeN;
if (cpN.length > 0) {
cpTotalN = parseFloat(cpN[0].Total);
cpSoldeN = parseFloat(cpN[0].Solde);
} else {
// Après bascule, si pas de ligne en base → 0
// Avant bascule, on calcule l'acquisition
if (currentYear > currentYearSys) {
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', 'Récup Dosée')
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé')
AND dc.Statut != 'Refusé'
`);
rttTotalN = rtt.acquisition;
rttSoldeN = Math.max(0, rttTotalN - consommeRTTResult.recordset[0].total);
}
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['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'
});
}
// ⭐ PUSH CP N (était manquant — cause du bug d'affichage)
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: parseFloat((cpTotalN - cpSoldeN).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
// ==================== 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
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const cpN1 = cpN1Result.recordset;
if (cpN1.length > 0 && cpN1[0].Solde > 0) {
const total = parseFloat(cpN1[0].Total);
const solde = parseFloat(cpN1[0].Solde);
resultats.push({
collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`,
email: collab.email,
service: collab.service,
campus: campusNom,
societe: collab.societe,
typeCongeId: typeCongeMap['Congé payé'],
typeConge: 'Congé payé',
annee: previousYear,
total,
solde,
consomme: 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);
}
const recupN = recupNResult.recordset;
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: parseFloat((rttTotalN - rttSoldeN).toFixed(2)),
role: collab.role,
typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
});
}
if (recupN.length > 0) {
const recupTotal = parseFloat(recupN[0].Total);
const recupSolde = parseFloat(recupN[0].Solde);
// ==================== 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
`);
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'
});
}
}
}
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: 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 });
}
});
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 });
}
});
// ================================================
@@ -5910,11 +5821,11 @@ app.get('/getAllDetailedCounters', async (req, res) => {
ca.nom,
ca.prenom,
ca.email,
ca.description as poste,
ca.role,
ca.fonction as poste,
ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree,
ca.actif as Actif,
ca.description,
-- Société
COALESCE(soc.Nom, 'Sans société') as societe,
@@ -6043,31 +5954,6 @@ app.get('/getAllDetailedCounters', async (req, res) => {
// 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 {
@@ -6087,11 +5973,11 @@ app.get('/getAllDetailedCounters', async (req, res) => {
campusId,
serviceId,
poste,
role,
typeContrat,
dateEntree,
nPlus1Id,
nPlus2Id
nPlus2Id,
description
} = req.body;
console.log('Modification collaborateur', id, req.body);
@@ -6115,10 +6001,10 @@ app.get('/getAllDetailedCounters', async (req, res) => {
.input('societeId', sql.Int, societeId)
.input('campusId', sql.Int, campusId)
.input('serviceId', sql.Int, serviceId)
.input('fonction', sql.NVarChar(255), poste)
.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)
.input('description', sql.NVarChar(500), description || null)
.query(`
UPDATE CollaborateurAD
SET nom = @nom,
@@ -6127,10 +6013,10 @@ app.get('/getAllDetailedCounters', async (req, res) => {
SocieteId = @societeId,
CampusId = @campusId,
ServiceId = @serviceId,
fonction = @fonction,
TypeContrat = @typeContrat,
DateEntree = @dateEntree,
description = @description,
role = @role
description = @description
WHERE id = @id
`);
-79
View File
@@ -1,79 +0,0 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface Validateur {
id: number;
nom: string;
email: string;
role: string;
}
interface ValidateurComboboxProps {
validateurs: Validateur[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function ValidateurCombobox({ validateurs, value, onChange, placeholder = "Sélectionner..." }: ValidateurComboboxProps) {
const [open, setOpen] = useState(false);
const selected = validateurs.find((v) => String(v.id) === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selected ? `${selected.nom} (${selected.role})` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder="Rechercher un validateur..." />
<CommandList>
<CommandEmpty>Aucun validateur trouvé.</CommandEmpty>
<CommandGroup>
{validateurs.map((v) => (
<CommandItem
key={v.id}
value={`${v.nom} ${v.email}`}
onSelect={() => {
onChange(String(v.id) === value ? "" : String(v.id));
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === String(v.id) ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{v.nom} <span className="text-muted-foreground">({v.role})</span></span>
<span className="text-xs text-muted-foreground">{v.email}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+3
View File
@@ -54,6 +54,9 @@ const AuthMicrosoft = () => {
// 🔑 Envoie accessToken au backend
const authenticateWithBackend = async (accessToken: string) => {
try {
console.log('🔑 Token complet:', accessToken);
console.log('🔑 Token (50 premiers):', accessToken.substring(0, 50));
console.log('🔑 Longueur:', accessToken.length);
const response = await fetch('/api/login-dev', {
method: 'POST',
headers: {
+1 -26
View File
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle,
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle,
Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList,
FileText, ChevronLeft, ChevronRight
} from "lucide-react";
@@ -55,8 +55,6 @@ interface Collaborateur {
campusId: number | null;
societe: string;
societeId: number | null;
dateEntree: string | null;
nPlus1Id: number | null;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -186,8 +184,6 @@ const Dashboard = () => {
campusId: c.campusId ?? null,
societe: c.societe || "",
societeId: c.societeId ?? null,
dateEntree: c.dateEntree ?? null,
nPlus1Id: c.nPlus1Id ?? null,
}))
);
}
@@ -389,12 +385,6 @@ const Dashboard = () => {
);
const usedTypes = [...new Set(leavesThisMonth.map(l => getTypeKey(l.typesConge)))];
// ── Profils à compléter (nouveaux arrivants synchronisés depuis Entra) ─────
const profilsIncompletsCount = allCollabs.filter(
c => !c.societeId || !c.campusId || !c.serviceId || !c.dateEntree || !c.nPlus1Id
).length;
const peutGererCollaborateurs = user?.role === "Admin" || user?.role === "RH";
// ── Cartes stats & actions ────────────────────────────────────────────────
const statsCards = [
{ title: "Demandes en attente", value: stats.enAttente, icon: Clock, color: "text-warning", bg: "bg-warning/10", action: () => navigate("/validation") },
@@ -449,21 +439,6 @@ const Dashboard = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Alerte profils à compléter */}
{peutGererCollaborateurs && profilsIncompletsCount > 0 && (
<Card
className="shadow-card border-0 border-l-4 border-l-amber-400 bg-amber-50 cursor-pointer hover:shadow-elegant transition-smooth"
onClick={() => navigate("/teams")}
>
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-900">
<strong>{profilsIncompletsCount} profil{profilsIncompletsCount > 1 ? "s" : ""}</strong> à compléter (société, campus, service, date d'entrée ou validateur manquant) — cliquez pour ouvrir la gestion des collaborateurs.
</p>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statsCards.map((stat, i) => (
+37 -31
View File
@@ -64,6 +64,7 @@ interface SoldesALaDate {
}>;
}
// ⭐ Interface prévisionnel (identique à SaisieManuelle)
interface Previsionnel {
mode: string;
isAnticipe: boolean;
@@ -370,7 +371,7 @@ const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps)
};
// =====================================================
// PANNEAU PRÉVISIONNEL
// PANNEAU PRÉVISIONNEL (réutilisé depuis SaisieManuelle)
// =====================================================
interface PrevisonnnelPanelProps {
@@ -399,6 +400,10 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
if (!previsionnel) return null;
const estOk = previsionnel.suffisantCP && previsionnel.suffisantRTT;
const cpInsuffisant = !previsionnel.suffisantCP && previsionnel.joursNecessairesCP > 0;
const rttInsuffisant = !previsionnel.suffisantRTT && previsionnel.joursNecessairesRTT > 0;
// En mode consultation (pas de saisie), on affiche toujours CP et RTT si dispo
const showCP = previsionnel.totalCPDisponible > 0 || previsionnel.cpNSolde > 0;
const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0;
@@ -424,6 +429,8 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* CP */}
{/* CP */}
{showCP && (
<div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2">
@@ -434,6 +441,7 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div>
<div className="space-y-1 text-xs text-gray-600">
{previsionnel.apresBasculement ? (
// ✅ APRÈS LE 1er JUIN : ancien CP N (devenu N-1) + nouveau CP N depuis juin
<>
{previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between">
@@ -459,6 +467,7 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
)}
</>
) : (
// ✅ AVANT LE 1er JUIN : CP N-1 + CP N en cours + anticipé
<>
{previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between">
@@ -494,6 +503,7 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div>
)}
{/* RTT */}
{showRTT && (
<div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2">
@@ -555,23 +565,22 @@ const GestionCompteurs = () => {
const [loading, setLoading] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
// Dialog consultation soldes à une date
const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false);
const [selectedCollaborateurId, setSelectedCollaborateurId] = useState<number | null>(null);
const [selectedEmployeName, setSelectedEmployeName] = useState<string>("");
const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]);
// ⭐ État prévisionnel
const [previsionnel, setPrevisionnel] = useState<Previsionnel | null>(null);
const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false);
// ✅ CORRECTION : currentYear + 1 = 2027 = année en cours après bascule
const currentYear = new Date().getFullYear();
const anneeEnCours = currentYear + 1; // 2027
const anneeN1 = currentYear; // 2026
const [editForm, setEditForm] = useState({ total: 0, solde: 0 });
useEffect(() => { chargerCompteurs(); }, []);
// ⭐ Recharger le prévisionnel quand la date ou le collaborateur change
useEffect(() => {
if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) {
chargerPrevisionnel();
@@ -585,6 +594,7 @@ const GestionCompteurs = () => {
return token ? { Authorization: `Bearer ${token}` } : {};
};
// ⭐ Chargement du prévisionnel
const chargerPrevisionnel = useCallback(async () => {
if (!selectedCollaborateurId || !dateConsultation) return;
@@ -782,8 +792,7 @@ const GestionCompteurs = () => {
<SelectItem value="all">Toutes les années</SelectItem>
{annees.map(a => (
<SelectItem key={a} value={a.toString()}>
{/* ✅ CORRECTION : En cours = 2027 */}
{a} {a === anneeEnCours && '(en cours)'}
{a} {a === currentYear && '(en cours)'}
</SelectItem>
))}
</SelectContent>
@@ -859,28 +868,19 @@ const GestionCompteurs = () => {
{compteur.typeConge}
</Badge>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<span className="font-medium">{compteur.annee}</span>
{/* CP 2027 = En cours */}
{compteur.annee === anneeEnCours && compteur.typeConge === 'Congé payé' && (
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge>
)}
{/* CP 2026 = N-1 */}
{compteur.annee === anneeN1 && compteur.typeConge === 'Congé payé' && (
<Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge>
)}
{/* RTT et Récupération 2026 = En cours */}
{compteur.annee === currentYear && compteur.typeConge !== 'Congé payé' && (
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">{compteur.total}</TableCell>
{/* ✅ CORRECTION : consommé jamais négatif */}
<TableCell className="text-right text-muted-foreground">
{compteur.consomme < 0 ? 0 : compteur.consomme}
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<span className="font-medium">{compteur.annee}</span>
{compteur.annee === currentYear && (
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge>
)}
{compteur.annee === currentYear - 1 && (
<Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right font-medium">{compteur.total}</TableCell>
<TableCell className="text-right text-muted-foreground">{compteur.consomme}</TableCell>
<TableCell className="text-right">
<span className={`font-bold text-lg ${compteur.solde <= 0 ? 'text-destructive' : compteur.total > 0 && compteur.solde / compteur.total <= 0.2 ? 'text-orange-600' : 'text-green-600'}`}>
{compteur.solde}
@@ -893,6 +893,7 @@ const GestionCompteurs = () => {
title="Modifier le compteur">
<Edit className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm"
onClick={() => ouvrirConsultationSoldes(compteur)}
title="Voir les soldes à une date spécifique"
@@ -911,7 +912,9 @@ const GestionCompteurs = () => {
</Card>
</main>
{/* DIALOG CONSULTATION SOLDES */}
{/* ⭐ DIALOG CONSULTATION SOLDES À UNE DATE + PRÉVISIONNEL */}
<Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -925,6 +928,7 @@ const GestionCompteurs = () => {
</DialogHeader>
<div className="space-y-4 py-2">
{/* Sélecteur de date */}
<div className="space-y-2">
<Label htmlFor="dateConsultation">Date de consultation</Label>
<Input
@@ -938,6 +942,7 @@ const GestionCompteurs = () => {
</p>
</div>
{/* ⭐ Panneau prévisionnel */}
{selectedCollaborateurId && dateConsultation && (
<PrevisonnnelPanel
collaborateurId={selectedCollaborateurId}
@@ -947,6 +952,7 @@ const GestionCompteurs = () => {
/>
)}
{/* Snapshot historique (dates passées uniquement) */}
{selectedCollaborateurId && dateConsultation &&
dateConsultation <= new Date().toISOString().split('T')[0] && (
<SnapshotDateDisplay
@@ -967,7 +973,7 @@ const GestionCompteurs = () => {
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifier le compteur </DialogTitle>
<DialogTitle>Modifier le compteur (V2)</DialogTitle>
<DialogDescription>
{selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`}
</DialogDescription>
@@ -1014,7 +1020,7 @@ const GestionCompteurs = () => {
};
// =====================================================
// SNAPSHOT À UNE DATE
// SNAPSHOT À UNE DATE (affiché en complément du prévisionnel pour les dates passées)
// =====================================================
interface SnapshotDateDisplayProps {
+17 -137
View File
@@ -33,14 +33,6 @@ interface Previsionnel {
suffisant: boolean; suffisantCP: boolean; suffisantRTT: boolean;
deficitCP: number; deficitRTT: number; message: string;
}
interface DoublonDemande {
id: number;
dateDebut: string;
dateFin: string;
statut: string;
nombreJours: number;
types: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const calcWorkingDays = (
@@ -425,8 +417,6 @@ const ImpactPreview = ({
// ═══════════════════════════════════════════════════════════════════════════════
const SaisieManuelle = () => {
const navigate = useNavigate();
const [doublonsInfo, setDoublonsInfo] = useState<Record<string, DoublonDemande[]>>({});
const [isCheckingDoublon, setIsCheckingDoublon] = useState(false);
const [employes, setEmployes] = useState<Employe[]>([]);
const [employesFiltres, setEmployesFiltres] = useState<Employe[]>([]);
@@ -466,40 +456,6 @@ const SaisieManuelle = () => {
]).then(([a, b]) => setPublicHolidays({ ...a, ...b })).catch(() => { });
}, []);
// ── Vérification anti-doublon ──────────────────────────────────────────
useEffect(() => {
let cancelled = false;
const checkDoublon = async () => {
if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) {
setDoublonsInfo({});
return;
}
setIsCheckingDoublon(true);
try {
const token = localStorage.getItem("token");
const p = new URLSearchParams({
collaborateurIds: employeIdsSelectionnes.join(","),
dateDebut,
dateFin,
});
const res = await fetch(`/api/demandes/check-doublon?${p}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (cancelled) return;
setDoublonsInfo(data.success && data.hasDoublon ? data.doublons : {});
} catch {
if (!cancelled) setDoublonsInfo({});
} finally {
if (!cancelled) setIsCheckingDoublon(false);
}
};
const debounceTimer = setTimeout(checkDoublon, 250);
return () => { cancelled = true; clearTimeout(debounceTimer); };
}, [employeIdsSelectionnes, dateDebut, dateFin]);
useEffect(() => {
const token = localStorage.getItem("token");
const h = { Authorization: `Bearer ${token}` };
@@ -612,30 +568,17 @@ const SaisieManuelle = () => {
return t?.nom.toLowerCase().includes("récup") && l.nombreJours > 0;
});
// ── Helper : nom d'un employé à partir de son ID
const getEmployeNom = (id: string) => {
const e = employes.find(emp => emp.id.toString() === id);
return e ? `${e.prenom} ${e.nom}` : `Collaborateur #${id}`;
};
const nbDoublons = Object.keys(doublonsInfo).length;
const handleSubmit = async () => {
setError("");
if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) {
setError("Veuillez sélectionner un collaborateur et remplir les dates."); return;
}
if (nbDoublons > 0) {
setError("Des doublons ont été détectés. Modifiez les dates ou retirez les collaborateurs concernés avant de continuer.");
return;
}
const lignesValides = lignesConge.filter(l => l.typeId > 0 && l.nombreJours > 0);
if (!lignesValides.length) {
setError("Ajoutez au moins un type de congé avec un nombre de jours."); return;
}
setLoading(true);
let dernierMessageErreur = ""; // ⭐ Capture le dernier message d'erreur précis
try {
const token = localStorage.getItem("token");
let ok = 0, ko = 0;
@@ -663,35 +606,20 @@ const SaisieManuelle = () => {
body: JSON.stringify({ jours: jJPOSF }),
});
}
} else {
ko++;
try {
const errData = await res.json();
console.error("❌ Erreur POST /demandes:", res.status, errData);
dernierMessageErreur = errData?.error || `Erreur ${res.status}`;
} catch {
dernierMessageErreur = `Erreur serveur (${res.status})`;
}
}
} catch (fetchErr) {
ko++;
console.error("❌ Exception fetch:", fetchErr);
dernierMessageErreur = "Impossible de contacter le serveur";
}
} else ko++;
} catch { ko++; }
}
if (ok > 0) {
toast.success(`✅ ${ok} demande(s) enregistrée(s) et validée(s)`, {
description: ko > 0 ? `${ko} erreur(s)` : undefined,
});
navigate("/dashboard");
} else {
// ⭐ Utilise le message précis capturé, sinon fallback générique
setError(dernierMessageErreur || "Aucune demande n'a pu être créée.");
}
} else throw new Error("Aucune demande créée");
} catch (e: any) {
setError(e.message || "Une erreur est survenue");
setError(e.message || "Erreur lors de l'enregistrement");
} finally { setLoading(false); }
};
// ═════════════════════════════════════════════════════════════════════════
// RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte)
// ═════════════════════════════════════════════════════════════════════════
@@ -836,21 +764,14 @@ const SaisieManuelle = () => {
{employesSelectionnes.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-3 bg-gray-50 border border-gray-200 rounded-lg">
{employesSelectionnes.map(e => {
const aUnDoublon = !!doublonsInfo[e.id.toString()];
return (
<Badge
key={e.id}
variant={aUnDoublon ? "destructive" : "secondary"}
className="px-2.5 py-1 text-sm"
>
{aUnDoublon && "⚠️ "}{e.nom} {e.prenom}
<button type="button" onClick={() => toggleEmploye(e.id.toString())} className="ml-1.5 hover:text-red-500">
<X className="w-3 h-3" />
</button>
</Badge>
);
})}
{employesSelectionnes.map(e => (
<Badge key={e.id} variant="secondary" className="px-2.5 py-1 text-sm">
{e.nom} {e.prenom}
<button type="button" onClick={() => toggleEmploye(e.id.toString())} className="ml-1.5 hover:text-red-500">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
@@ -945,44 +866,6 @@ const SaisieManuelle = () => {
)}
</div>
)}
{/* ── Bandeau anti-doublon ────────────────────────────── */}
{isCheckingDoublon && (
<div className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-4 py-2 text-xs text-gray-500">
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
Vérification des doublons…
</div>
)}
{!isCheckingDoublon && nbDoublons > 0 && (
<div className="bg-red-50 border-2 border-red-300 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-red-800">
⚠️ Doublon détecté pour {nbDoublons} collaborateur{nbDoublons > 1 ? "s" : ""}
</p>
<div className="mt-2 space-y-2">
{Object.entries(doublonsInfo).map(([empId, demandes]) => (
<div key={empId} className="bg-white border border-red-200 rounded px-2 py-1.5">
<p className="text-xs font-semibold text-gray-800">{getEmployeNom(empId)}</p>
{demandes.map(d => (
<div key={d.id} className="text-xs text-gray-600 mt-0.5">
{d.types || "Congé"} — {fmtDate(d.dateDebut, { day: "2-digit", month: "short" })}
{d.dateDebut !== d.dateFin && ` au ${fmtDate(d.dateFin, { day: "2-digit", month: "short" })}`}
{" "}({d.nombreJours}j) — <span className={d.statut === "En attente" ? "text-orange-600" : "text-green-600"}>{d.statut}</span>
</div>
))}
</div>
))}
</div>
<p className="text-xs text-red-700 mt-2">
Modifiez les dates ou retirez ces collaborateurs de la sélection pour continuer.
</p>
</div>
</div>
</div>
)}
</div>
</div>
@@ -1086,16 +969,13 @@ const SaisieManuelle = () => {
Annuler
</button>
<button
type="button" onClick={handleSubmit}
disabled={loading || isCheckingDoublon || nbDoublons > 0}
type="button" onClick={handleSubmit} disabled={loading}
className="flex-1 px-4 py-2.5 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-all bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50"
>
{loading ? "Enregistrement"
: nbDoublons > 0
? <><AlertCircle className="w-4 h-4" /> Doublon détecté</>
: previsionnel && !previsionnel.suffisant
? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</>
: <><CheckCircle className="w-4 h-4" /> Enregistrer et valider</>}
: previsionnel && !previsionnel.suffisant
? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</>
: <><CheckCircle className="w-4 h-4" /> Enregistrer et valider</>}
</button>
</div>
</div>
+3 -327
View File
@@ -4,20 +4,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp, UserCog, AlertTriangle } from "lucide-react";
import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ValidateurCombobox } from "@/components/ValidateurCombobox";
interface Collaborateur {
id: number;
@@ -31,9 +22,9 @@ interface Collaborateur {
service: string;
serviceId: number | null;
poste: string;
role: string;
typeContrat: string;
dateEntree: string;
description: string;
soldeCPN: number;
soldeCPNMoins1: number;
soldeRTT: number;
@@ -62,41 +53,6 @@ interface Service {
nom: string;
}
interface Validateur {
id: number;
nom: string;
email: string;
role: string;
}
const ROLES_DISPONIBLES = [
"Collaborateur", "Collaboratrice",
"Validateur", "Validatrice",
"RH", "Admin",
"Directeur de campus", "Directrice de campus",
"President",
"Apprenti", "Stagiaire",
];
const EDITION_INITIALE = {
id: 0,
nom: "",
prenom: "",
email: "",
societeId: "",
campusId: "",
serviceId: "",
poste: "",
role: "",
typeContrat: "",
dateEntree: "",
nPlus1Id: "",
nPlus2Id: "",
};
const estProfilIncomplet = (c: Collaborateur) =>
!c.societeId || !c.campusId || !c.serviceId || !c.dateEntree || !c.nPlus1Id;
const Teams = () => {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
@@ -113,95 +69,10 @@ const Teams = () => {
const [loading, setLoading] = useState(true);
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const [validateurs, setValidateurs] = useState<Validateur[]>([]);
const [editionOuverte, setEditionOuverte] = useState(false);
const [edition, setEdition] = useState(EDITION_INITIALE);
const [enregistrement, setEnregistrement] = useState(false);
useEffect(() => {
chargerDonnees();
}, []);
const chargerValidateurs = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/collaborateurs/validateurs', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
setValidateurs(await response.json());
}
} catch (error) {
console.error('Erreur chargement validateurs:', error);
}
};
const ouvrirEdition = (collab: Collaborateur) => {
setEdition({
id: collab.id,
nom: collab.nom || "",
prenom: collab.prenom || "",
email: collab.email || "",
societeId: collab.societeId ? String(collab.societeId) : "",
campusId: collab.campusId ? String(collab.campusId) : "",
serviceId: collab.serviceId ? String(collab.serviceId) : "",
poste: collab.poste || "",
role: collab.role || "",
typeContrat: collab.typeContrat || "",
dateEntree: collab.dateEntree ? collab.dateEntree.split("T")[0] : "",
nPlus1Id: collab.nPlus1Id ? String(collab.nPlus1Id) : "",
nPlus2Id: collab.nPlus2Id ? String(collab.nPlus2Id) : "",
});
chargerValidateurs();
setEditionOuverte(true);
};
const enregistrerEdition = async () => {
if (!edition.role) {
toast.error("Le rôle est obligatoire");
return;
}
setEnregistrement(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/collaborateurs/${edition.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
nom: edition.nom,
prenom: edition.prenom,
email: edition.email,
societeId: edition.societeId ? parseInt(edition.societeId) : null,
campusId: edition.campusId ? parseInt(edition.campusId) : null,
serviceId: edition.serviceId ? parseInt(edition.serviceId) : null,
poste: edition.poste || null,
role: edition.role || null,
typeContrat: edition.typeContrat || null,
dateEntree: edition.dateEntree || null,
nPlus1Id: edition.nPlus1Id ? parseInt(edition.nPlus1Id) : null,
nPlus2Id: edition.nPlus2Id ? parseInt(edition.nPlus2Id) : null,
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erreur lors de la mise à jour");
}
toast.success("Collaborateur mis à jour avec succès");
setEditionOuverte(false);
chargerDonnees();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erreur lors de la mise à jour");
} finally {
setEnregistrement(false);
}
};
const chargerDonnees = async () => {
setLoading(true);
try {
@@ -264,23 +135,6 @@ const Teams = () => {
}
};
const getRoleBadge = (role: string) => {
switch (role) {
case 'Admin':
return <Badge variant="destructive">{role}</Badge>;
case 'RH':
return <Badge variant="default">{role}</Badge>;
case 'Validateur':
return <Badge variant="secondary">{role}</Badge>;
case 'Directeur de campus':
return <Badge className="bg-purple-600">{role}</Badge>;
case 'Apprenti':
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300">{role}</Badge>;
default:
return <Badge variant="outline">{role || 'Non défini'}</Badge>;
}
};
const getTypeContratBadge = (type: string) => {
switch (type) {
case 'forfait_jour':
@@ -316,9 +170,6 @@ const Teams = () => {
`${a.nom} ${a.prenom}`.localeCompare(`${b.nom} ${b.prenom}`)
);
// Profils incomplets (nouveaux arrivants synchronisés depuis Entra sans société/campus/service/date d'entrée/validateur)
const profilsIncomplets = collaborateurs.filter(estProfilIncomplet);
// Listes uniques pour les filtres
const societesUniques = [...new Set(collaborateurs.map(c => c.societe).filter(Boolean))].sort();
const campusUniques = [...new Set(collaborateurs.map(c => c.campus).filter(Boolean))].sort();
@@ -354,182 +205,11 @@ const Teams = () => {
</div>
</div>
</div>
{profilsIncomplets.length > 0 && (
<Badge variant="outline" className="bg-amber-100 text-amber-800 border-amber-300 gap-1.5 px-3 py-1.5">
<AlertTriangle className="w-4 h-4" />
{profilsIncomplets.length} profil{profilsIncomplets.length > 1 ? 's' : ''} à compléter
</Badge>
)}
</div>
</div>
</header>
<Dialog open={editionOuverte} onOpenChange={setEditionOuverte}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Modifier {edition.prenom} {edition.nom}</DialogTitle>
<DialogDescription>
Renseignez la date d'entrée, le rôle, le validateur, la société, le campus, le service et le type de contrat.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-2">
<div className="space-y-2">
<Label htmlFor="ed-dateEntree">Date d'entrée</Label>
<Input
id="ed-dateEntree"
type="date"
value={edition.dateEntree}
onChange={(e) => setEdition({ ...edition, dateEntree: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Rôle</Label>
<Select
value={edition.role}
onValueChange={(v) => setEdition({ ...edition, role: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{ROLES_DISPONIBLES.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Type de contrat</Label>
<Select
value={edition.typeContrat}
onValueChange={(v) => setEdition({ ...edition, typeContrat: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="forfait_jour">Forfait jours</SelectItem>
<SelectItem value="37h">37h</SelectItem>
<SelectItem value="Apprentissage">Alternant</SelectItem>
<SelectItem value="tempspartiel">Temps partiel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Société</Label>
<Select
value={edition.societeId}
onValueChange={(v) => setEdition({ ...edition, societeId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{societes.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Campus</Label>
<Select
value={edition.campusId}
onValueChange={(v) => setEdition({ ...edition, campusId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{campusList.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Service</Label>
<Select
value={edition.serviceId}
onValueChange={(v) => setEdition({ ...edition, serviceId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{services.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Validateur (N+1)</Label>
<ValidateurCombobox
validateurs={validateurs}
value={edition.nPlus1Id}
onChange={(v) => setEdition({ ...edition, nPlus1Id: v })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditionOuverte(false)} disabled={enregistrement}>
Annuler
</Button>
<Button onClick={enregistrerEdition} disabled={enregistrement}>
{enregistrement ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Profils à compléter (nouveaux arrivants synchronisés depuis Entra) */}
{profilsIncomplets.length > 0 && (
<Card className="shadow-card border-0 mb-8 border-l-4 border-l-amber-400">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<CardTitle>Profils à compléter</CardTitle>
</div>
<CardDescription>
Ces collaborateurs ont é créés par la synchronisation Entra mais il manque leur société, campus, service, date d'entrée ou validateur.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{profilsIncomplets.map((collab) => (
<div
key={collab.id}
className="flex items-center justify-between p-3 rounded-lg border border-amber-200 bg-amber-50"
>
<div>
<p className="font-medium text-sm">{collab.nom} {collab.prenom}</p>
<p className="text-xs text-muted-foreground">{collab.email}</p>
<div className="flex flex-wrap gap-1 mt-1">
{!collab.societeId && <Badge variant="outline" className="text-xs bg-white">Société manquante</Badge>}
{!collab.campusId && <Badge variant="outline" className="text-xs bg-white">Campus manquant</Badge>}
{!collab.serviceId && <Badge variant="outline" className="text-xs bg-white">Service manquant</Badge>}
{!collab.dateEntree && <Badge variant="outline" className="text-xs bg-white">Date d'entrée manquante</Badge>}
{!collab.nPlus1Id && <Badge variant="outline" className="text-xs bg-white">Validateur manquant</Badge>}
</div>
</div>
<Button size="sm" onClick={() => ouvrirEdition(collab)}>
<UserCog className="w-4 h-4 mr-2" />
Compléter
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Filtres */}
<Card className="shadow-card border-0 mb-8">
<CardContent className="p-6">
@@ -628,8 +308,6 @@ const Teams = () => {
<TableHead className="w-8"></TableHead>
<TableHead>Nom / Prénom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Poste</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Société</TableHead>
<TableHead>Campus</TableHead>
<TableHead>Service</TableHead>
@@ -661,8 +339,6 @@ const Teams = () => {
<TableCell className="text-muted-foreground">
{collab.email}
</TableCell>
<TableCell className="text-muted-foreground">{collab.poste || '-'}</TableCell>
<TableCell>{getRoleBadge(collab.role)}</TableCell>
<TableCell>{collab.societe || '-'}</TableCell>
<TableCell>{collab.campus || '-'}</TableCell>
<TableCell>{collab.service || '-'}</TableCell>
@@ -692,7 +368,7 @@ const Teams = () => {
{/* Ligne de détails expansible */}
{expandedRows.has(collab.id) && (
<TableRow className="bg-muted/30">
<TableCell colSpan={13}>
<TableCell colSpan={11}>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Infos personnelles */}
<div className="space-y-2">