Compare commits
2 Commits
prod
..
314a98c1ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 314a98c1ad | |||
| a7a0e51c7a |
+387
-273
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
@@ -943,7 +943,23 @@ 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';
|
||||
|
||||
@@ -2457,6 +2473,82 @@ 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
|
||||
@@ -5505,297 +5597,294 @@ WHERE CollaborateurADId = @userId
|
||||
});
|
||||
|
||||
// ✅ 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; // CP N (2026 ou 2027 après bascule)
|
||||
let previousYear = currentYearSys - 1; // CP N-1
|
||||
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
|
||||
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 (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
|
||||
`);
|
||||
|
||||
const typeCongeMap = {};
|
||||
typesCongeResult.recordset.forEach(tc => {
|
||||
typeCongeMap[tc.Nom] = tc.Id;
|
||||
});
|
||||
if (checkBascule.recordset.length > 0) {
|
||||
currentYear = currentYearSys + 1; // 2027
|
||||
previousYear = currentYearSys; // 2026
|
||||
console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
|
||||
}
|
||||
}
|
||||
|
||||
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 anneeRTT = currentYearSys;
|
||||
const anneeRecup = currentYearSys;
|
||||
|
||||
const collaborateurs = collaborateursResult.recordset;
|
||||
const resultats = [];
|
||||
console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
|
||||
|
||||
console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
|
||||
const typesCongeResult = await pool.request().query(`
|
||||
SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Récupération')
|
||||
`);
|
||||
|
||||
for (const collab of collaborateurs) {
|
||||
const campusNom = collab.campusNom;
|
||||
const typeCongeMap = {};
|
||||
typesCongeResult.recordset.forEach(tc => {
|
||||
typeCongeMap[tc.Nom] = tc.Id;
|
||||
});
|
||||
|
||||
// ==================== 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 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 cpN = cpNResult.recordset;
|
||||
const collaborateurs = collaborateursResult.recordset;
|
||||
const resultats = [];
|
||||
|
||||
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(`
|
||||
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', 'N Anticipé')
|
||||
AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée')
|
||||
AND dc.Statut != 'Refusé'
|
||||
`);
|
||||
cpSoldeN = Math.max(0, cpTotalN - consommeResult.recordset[0].total);
|
||||
}
|
||||
}
|
||||
rttTotalN = rtt.acquisition;
|
||||
rttSoldeN = Math.max(0, rttTotalN - consommeRTTResult.recordset[0].total);
|
||||
}
|
||||
|
||||
// ⭐ 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'
|
||||
});
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 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é'
|
||||
// ==================== 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
|
||||
`);
|
||||
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: parseFloat((rttTotalN - rttSoldeN).toFixed(2)),
|
||||
role: collab.role,
|
||||
typeContrat: collab.TypeContrat,
|
||||
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
|
||||
});
|
||||
}
|
||||
const recupN = recupNResult.recordset;
|
||||
|
||||
// ==================== 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
|
||||
`);
|
||||
if (recupN.length > 0) {
|
||||
const recupTotal = parseFloat(recupN[0].Total);
|
||||
const recupSolde = parseFloat(recupN[0].Solde);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ================================================
|
||||
@@ -5821,11 +5910,11 @@ WHERE CollaborateurADId = @userId
|
||||
ca.nom,
|
||||
ca.prenom,
|
||||
ca.email,
|
||||
ca.fonction as poste,
|
||||
ca.description as poste,
|
||||
ca.role,
|
||||
ca.TypeContrat as typeContrat,
|
||||
ca.DateEntree as dateEntree,
|
||||
ca.actif as Actif,
|
||||
ca.description,
|
||||
|
||||
-- Société
|
||||
COALESCE(soc.Nom, 'Sans société') as societe,
|
||||
@@ -5954,6 +6043,31 @@ WHERE CollaborateurADId = @userId
|
||||
|
||||
|
||||
// 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 {
|
||||
@@ -5973,11 +6087,11 @@ WHERE CollaborateurADId = @userId
|
||||
campusId,
|
||||
serviceId,
|
||||
poste,
|
||||
role,
|
||||
typeContrat,
|
||||
dateEntree,
|
||||
nPlus1Id,
|
||||
nPlus2Id,
|
||||
description
|
||||
nPlus2Id
|
||||
} = req.body;
|
||||
|
||||
console.log('Modification collaborateur', id, req.body);
|
||||
@@ -6001,10 +6115,10 @@ WHERE CollaborateurADId = @userId
|
||||
.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), description || null)
|
||||
.input('description', sql.NVarChar(500), poste || null)
|
||||
.input('role', sql.NVarChar(100), role || null)
|
||||
.query(`
|
||||
UPDATE CollaborateurAD
|
||||
SET nom = @nom,
|
||||
@@ -6013,10 +6127,10 @@ WHERE CollaborateurADId = @userId
|
||||
SocieteId = @societeId,
|
||||
CampusId = @campusId,
|
||||
ServiceId = @serviceId,
|
||||
fonction = @fonction,
|
||||
TypeContrat = @typeContrat,
|
||||
DateEntree = @dateEntree,
|
||||
description = @description
|
||||
description = @description,
|
||||
role = @role
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -54,9 +54,6 @@ 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: {
|
||||
|
||||
+26
-1
@@ -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,
|
||||
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle,
|
||||
Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList,
|
||||
FileText, ChevronLeft, ChevronRight
|
||||
} from "lucide-react";
|
||||
@@ -55,6 +55,8 @@ interface Collaborateur {
|
||||
campusId: number | null;
|
||||
societe: string;
|
||||
societeId: number | null;
|
||||
dateEntree: string | null;
|
||||
nPlus1Id: number | null;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -184,6 +186,8 @@ const Dashboard = () => {
|
||||
campusId: c.campusId ?? null,
|
||||
societe: c.societe || "",
|
||||
societeId: c.societeId ?? null,
|
||||
dateEntree: c.dateEntree ?? null,
|
||||
nPlus1Id: c.nPlus1Id ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -385,6 +389,12 @@ 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") },
|
||||
@@ -439,6 +449,21 @@ 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) => (
|
||||
|
||||
@@ -64,7 +64,6 @@ interface SoldesALaDate {
|
||||
}>;
|
||||
}
|
||||
|
||||
// ⭐ Interface prévisionnel (identique à SaisieManuelle)
|
||||
interface Previsionnel {
|
||||
mode: string;
|
||||
isAnticipe: boolean;
|
||||
@@ -371,7 +370,7 @@ const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps)
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// ⭐ PANNEAU PRÉVISIONNEL (réutilisé depuis SaisieManuelle)
|
||||
// PANNEAU PRÉVISIONNEL
|
||||
// =====================================================
|
||||
|
||||
interface PrevisonnnelPanelProps {
|
||||
@@ -400,10 +399,6 @@ 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;
|
||||
|
||||
@@ -429,8 +424,6 @@ 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">
|
||||
@@ -441,7 +434,6 @@ 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">
|
||||
@@ -467,7 +459,6 @@ 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">
|
||||
@@ -503,7 +494,6 @@ 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">
|
||||
@@ -565,22 +555,23 @@ 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();
|
||||
@@ -594,7 +585,6 @@ const GestionCompteurs = () => {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
// ⭐ Chargement du prévisionnel
|
||||
const chargerPrevisionnel = useCallback(async () => {
|
||||
if (!selectedCollaborateurId || !dateConsultation) return;
|
||||
|
||||
@@ -792,7 +782,8 @@ const GestionCompteurs = () => {
|
||||
<SelectItem value="all">Toutes les années</SelectItem>
|
||||
{annees.map(a => (
|
||||
<SelectItem key={a} value={a.toString()}>
|
||||
{a} {a === currentYear && '(en cours)'}
|
||||
{/* ✅ CORRECTION : En cours = 2027 */}
|
||||
{a} {a === anneeEnCours && '(en cours)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -868,19 +859,28 @@ 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>
|
||||
{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-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>
|
||||
<TableCell className="text-right text-muted-foreground">{compteur.consomme}</TableCell>
|
||||
{/* ✅ CORRECTION : consommé jamais négatif */}
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{compteur.consomme < 0 ? 0 : 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,7 +893,6 @@ 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"
|
||||
@@ -912,9 +911,7 @@ const GestionCompteurs = () => {
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
{/* ⭐ DIALOG CONSULTATION SOLDES À UNE DATE + PRÉVISIONNEL */}
|
||||
{/* DIALOG CONSULTATION SOLDES */}
|
||||
<Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
@@ -928,7 +925,6 @@ 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
|
||||
@@ -942,7 +938,6 @@ const GestionCompteurs = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ⭐ Panneau prévisionnel */}
|
||||
{selectedCollaborateurId && dateConsultation && (
|
||||
<PrevisonnnelPanel
|
||||
collaborateurId={selectedCollaborateurId}
|
||||
@@ -952,7 +947,6 @@ const GestionCompteurs = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Snapshot historique (dates passées uniquement) */}
|
||||
{selectedCollaborateurId && dateConsultation &&
|
||||
dateConsultation <= new Date().toISOString().split('T')[0] && (
|
||||
<SnapshotDateDisplay
|
||||
@@ -973,7 +967,7 @@ const GestionCompteurs = () => {
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le compteur (V2)</DialogTitle>
|
||||
<DialogTitle>Modifier le compteur </DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`}
|
||||
</DialogDescription>
|
||||
@@ -1020,7 +1014,7 @@ const GestionCompteurs = () => {
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// ⭐ SNAPSHOT À UNE DATE (affiché en complément du prévisionnel pour les dates passées)
|
||||
// SNAPSHOT À UNE DATE
|
||||
// =====================================================
|
||||
|
||||
interface SnapshotDateDisplayProps {
|
||||
|
||||
+139
-19
@@ -33,6 +33,14 @@ 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 = (
|
||||
@@ -417,6 +425,8 @@ 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[]>([]);
|
||||
@@ -456,6 +466,40 @@ 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}` };
|
||||
@@ -568,17 +612,30 @@ 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;
|
||||
@@ -606,20 +663,35 @@ const SaisieManuelle = () => {
|
||||
body: JSON.stringify({ jours: jJPOSF }),
|
||||
});
|
||||
}
|
||||
} else ko++;
|
||||
} catch { ko++; }
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
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 throw new Error("Aucune demande créée");
|
||||
} else {
|
||||
// ⭐ Utilise le message précis capturé, sinon fallback générique
|
||||
setError(dernierMessageErreur || "Aucune demande n'a pu être créée.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Erreur lors de l'enregistrement");
|
||||
setError(e.message || "Une erreur est survenue");
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
// RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte)
|
||||
// ═════════════════════════════════════════════════════════════════════════
|
||||
@@ -764,14 +836,21 @@ 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 => (
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -866,6 +945,44 @@ 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>
|
||||
|
||||
@@ -969,13 +1086,16 @@ const SaisieManuelle = () => {
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button" onClick={handleSubmit} disabled={loading}
|
||||
type="button" onClick={handleSubmit}
|
||||
disabled={loading || isCheckingDoublon || nbDoublons > 0}
|
||||
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…"
|
||||
: previsionnel && !previsionnel.suffisant
|
||||
? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</>
|
||||
: <><CheckCircle className="w-4 h-4" /> Enregistrer et valider</>}
|
||||
: 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</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1112,4 +1232,4 @@ const SaisieManuelle = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SaisieManuelle;
|
||||
export default SaisieManuelle;
|
||||
+327
-3
@@ -4,11 +4,20 @@ 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 } from "lucide-react";
|
||||
import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp, UserCog, AlertTriangle } 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;
|
||||
@@ -22,9 +31,9 @@ interface Collaborateur {
|
||||
service: string;
|
||||
serviceId: number | null;
|
||||
poste: string;
|
||||
role: string;
|
||||
typeContrat: string;
|
||||
dateEntree: string;
|
||||
description: string;
|
||||
soldeCPN: number;
|
||||
soldeCPNMoins1: number;
|
||||
soldeRTT: number;
|
||||
@@ -53,6 +62,41 @@ 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("");
|
||||
@@ -69,10 +113,95 @@ 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 {
|
||||
@@ -135,6 +264,23 @@ 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':
|
||||
@@ -170,6 +316,9 @@ 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();
|
||||
@@ -205,11 +354,182 @@ 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 été 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">
|
||||
@@ -308,6 +628,8 @@ 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>
|
||||
@@ -339,6 +661,8 @@ 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>
|
||||
@@ -368,7 +692,7 @@ const Teams = () => {
|
||||
{/* Ligne de détails expansible */}
|
||||
{expandedRows.has(collab.id) && (
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableCell colSpan={11}>
|
||||
<TableCell colSpan={13}>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user