2 Commits

Author SHA1 Message Date
oimer 314a98c1ad Ajouter completion des profils nouveaux arrivants et corrections diverses
- Détection des profils incomplets (société/campus/service/date d'entrée/validateur) avec alerte Dashboard et section dédiée dans Équipe
- Formulaire d'édition des collaborateurs (date d'entrée, rôle, validateur N+1, société, campus, service, type de contrat) avec combobox de recherche pour le validateur
- Correction du mapping poste (colonne description au lieu de fonction) et du filtre des validateurs (variantes de rôle: Validateur/Validatrice, Directeur/Directrice, President)
- Fix du proxy Vite vers le backend Docker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 15:37:03 +02:00
oimer a7a0e51c7a Modif_GestionDoublonsGTARH_Fonctionnel_V1 2025-12-11 16:47:21 +01:00
7 changed files with 989 additions and 336 deletions
+387 -273
View File
@@ -1,4 +1,4 @@

const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
@@ -943,7 +943,23 @@ try {
await transaction.rollback(); await transaction.rollback();
return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' }); 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 totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
const statut = saisieManuelle ? 'Validée' : 'En attente'; 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 // ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE
@@ -5505,297 +5597,294 @@ WHERE CollaborateurADId = @userId
}); });
// ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters // ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters
app.get('/getAllDetailedCounters', async (req, res) => { app.get('/getAllDetailedCounters', async (req, res) => {
try { try {
console.log('📊 Récupération de TOUS les compteurs détaillés'); console.log('📊 Récupération de TOUS les compteurs détaillés');
const today = new Date(); const today = new Date();
const currentYearSys = today.getFullYear(); // 2026 const currentYearSys = today.getFullYear(); // 2026
// ── Détection bascule CP ────────────────────────────────────────── // ── Détection bascule CP ──────────────────────────────────────────
const cpTypeForCheck = await pool.request() const cpTypeForCheck = await pool.request()
.query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'"); .query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'");
let currentYear = currentYearSys; // CP N (2026 ou 2027 après bascule) let currentYear = currentYearSys;
let previousYear = currentYearSys - 1; // CP N-1 let previousYear = currentYearSys - 1;
if (cpTypeForCheck.recordset.length > 0) { if (cpTypeForCheck.recordset.length > 0) {
const cpTypeId = cpTypeForCheck.recordset[0].Id; const cpTypeId = cpTypeForCheck.recordset[0].Id;
const checkBascule = await pool.request() const checkBascule = await pool.request()
.input('cpTypeId', sql.Int, cpTypeId) .input('cpTypeId', sql.Int, cpTypeId)
.input('anneeN1', sql.Int, currentYearSys + 1) .input('anneeN1', sql.Int, currentYearSys + 1)
.query(` .query(`
SELECT TOP 1 Id FROM CompteurConges SELECT TOP 1 Id FROM CompteurConges
WHERE TypeCongeId = @cpTypeId WHERE TypeCongeId = @cpTypeId
AND Annee = @anneeN1 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')
`); `);
const typeCongeMap = {}; if (checkBascule.recordset.length > 0) {
typesCongeResult.recordset.forEach(tc => { currentYear = currentYearSys + 1; // 2027
typeCongeMap[tc.Nom] = tc.Id; previousYear = currentYearSys; // 2026
}); console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
}
}
const collaborateursResult = await pool.request().query(` const anneeRTT = currentYearSys;
SELECT const anneeRecup = currentYearSys;
ca.id,
ca.prenom,
ca.nom,
ca.email,
ca.role,
ca.TypeContrat,
ca.DateEntree,
COALESCE(s.Nom, 'Non assigné') as service,
s.Id as serviceId,
ca.CampusId as campusId,
COALESCE(camp.Nom, 'Sans campus') as campusNom,
ca.SocieteId as societeId,
COALESCE(soc.Nom, 'Sans société') as societe
FROM CollaborateurAD ca
LEFT JOIN Services s ON ca.ServiceId = s.Id
LEFT JOIN Campus camp ON ca.CampusId = camp.Id
LEFT JOIN Societe soc ON ca.SocieteId = soc.Id
WHERE (ca.Actif = 1 OR ca.Actif IS NULL)
AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%')
ORDER BY soc.Nom, camp.Nom, s.Nom, ca.nom, ca.prenom
`);
const collaborateurs = collaborateursResult.recordset; console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
const resultats = [];
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 typeCongeMap = {};
const campusNom = collab.campusNom; typesCongeResult.recordset.forEach(tc => {
typeCongeMap[tc.Nom] = tc.Id;
});
// ==================== CP N ==================== const collaborateursResult = await pool.request().query(`
if (typeCongeMap['Congé payé']) { SELECT
const cpNResult = await pool.request() ca.id,
.input('collabId', sql.Int, collab.id) ca.prenom,
.input('typeId', sql.Int, typeCongeMap['Congé payé']) ca.nom,
.input('annee', sql.Int, currentYear) ca.email,
.query(` ca.role,
SELECT Total, Solde ca.TypeContrat,
FROM CompteurConges ca.DateEntree,
WHERE CollaborateurADId = @collabId COALESCE(s.Nom, 'Non assigné') as service,
AND TypeCongeId = @typeId s.Id as serviceId,
AND Annee = @annee 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; console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
if (cpN.length > 0) {
cpTotalN = parseFloat(cpN[0].Total); for (const collab of collaborateurs) {
cpSoldeN = parseFloat(cpN[0].Solde); const campusNom = collab.campusNom;
} else {
// Après bascule, si pas de ligne en base → 0 // ==================== CP N ====================
// Avant bascule, on calcule l'acquisition if (typeCongeMap['Congé payé']) {
if (currentYear > currentYearSys) { const cpNResult = await pool.request()
cpTotalN = 0; .input('collabId', sql.Int, collab.id)
cpSoldeN = 0; .input('typeId', sql.Int, typeCongeMap['Congé payé'])
} else { .input('annee', sql.Int, currentYear)
cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree); .query(`
const consommeResult = await pool.request() SELECT Total, Solde, SoldeReporte
.input('collabId', sql.Int, collab.id) FROM CompteurConges
.input('typeId', sql.Int, typeCongeMap['Congé payé']) WHERE CollaborateurADId = @collabId
.input('annee', sql.Int, currentYear) AND TypeCongeId = @typeId
.query(` 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 SELECT COALESCE(SUM(JoursUtilises), 0) AS total
FROM DeductionDetails dd FROM DeductionDetails dd
JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
WHERE dc.CollaborateurADId = @collabId WHERE dc.CollaborateurADId = @collabId
AND dd.TypeCongeId = @typeId AND dd.TypeCongeId = @typeId
AND dd.Annee = @annee 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é' 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({
resultats.push({ collaborateurId: collab.id,
collaborateurId: collab.id, employe: `${collab.prenom} ${collab.nom}`,
employe: `${collab.prenom} ${collab.nom}`, email: collab.email,
email: collab.email, service: collab.service,
service: collab.service, campus: campusNom,
campus: campusNom, societe: collab.societe,
societe: collab.societe, typeCongeId: typeCongeMap['RTT'],
typeCongeId: typeCongeMap['Congé payé'], typeConge: 'RTT',
typeConge: 'Congé payé', annee: anneeRTT,
annee: currentYear, total: parseFloat(rttTotalN.toFixed(2)),
total: parseFloat(cpTotalN.toFixed(2)), solde: parseFloat(rttSoldeN.toFixed(2)),
solde: parseFloat(cpSoldeN.toFixed(2)), consomme: Math.max(0, parseFloat((rttTotalN - rttSoldeN).toFixed(2))),
consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)), role: collab.role,
role: collab.role, typeContrat: collab.TypeContrat,
typeContrat: collab.TypeContrat, estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' });
}); }
// ==================== CP N-1 ==================== // ==================== Récupération — toujours anneeRecup (2026) ====================
const cpN1Result = await pool.request() if (typeCongeMap['Récupération']) {
.input('collabId', sql.Int, collab.id) const recupNResult = await pool.request()
.input('typeId', sql.Int, typeCongeMap['Congé payé']) .input('collabId', sql.Int, collab.id)
.input('annee', sql.Int, previousYear) .input('typeId', sql.Int, typeCongeMap['Récupération'])
.query(` .input('annee', sql.Int, anneeRecup)
SELECT Total, Solde .query(`
FROM CompteurConges SELECT Total, Solde
WHERE CollaborateurADId = @collabId FROM CompteurConges
AND TypeCongeId = @typeId WHERE CollaborateurADId = @collabId
AND Annee = @annee 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);
}
resultats.push({ const recupN = recupNResult.recordset;
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'
});
}
// ==================== Récupération — toujours anneeRecup (2026) ==================== if (recupN.length > 0) {
if (typeCongeMap['Récupération']) { const recupTotal = parseFloat(recupN[0].Total);
const recupNResult = await pool.request() const recupSolde = parseFloat(recupN[0].Solde);
.input('collabId', sql.Int, collab.id)
.input('typeId', sql.Int, typeCongeMap['Récupération'])
.input('annee', sql.Int, anneeRecup)
.query(`
SELECT Total, Solde
FROM CompteurConges
WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId
AND Annee = @annee
`);
const recupN = recupNResult.recordset; resultats.push({
collaborateurId: collab.id,
if (recupN.length > 0) { employe: `${collab.prenom} ${collab.nom}`,
const recupTotal = parseFloat(recupN[0].Total); email: collab.email,
const recupSolde = parseFloat(recupN[0].Solde); service: collab.service,
campus: campusNom,
resultats.push({ societe: collab.societe,
collaborateurId: collab.id, typeCongeId: typeCongeMap['Récupération'],
employe: `${collab.prenom} ${collab.nom}`, typeConge: 'Récupération',
email: collab.email, annee: anneeRecup,
service: collab.service, total: recupTotal,
campus: campusNom, solde: recupSolde,
societe: collab.societe, consomme: Math.max(0, parseFloat((recupTotal - recupSolde).toFixed(2))),
typeCongeId: typeCongeMap['Récupération'], role: collab.role,
typeConge: 'Récupération', typeContrat: collab.TypeContrat,
annee: anneeRecup, estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
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 });
}
});
// ================================================ // ================================================
@@ -5821,11 +5910,11 @@ WHERE CollaborateurADId = @userId
ca.nom, ca.nom,
ca.prenom, ca.prenom,
ca.email, ca.email,
ca.fonction as poste, ca.description as poste,
ca.role,
ca.TypeContrat as typeContrat, ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree, ca.DateEntree as dateEntree,
ca.actif as Actif, ca.actif as Actif,
ca.description,
-- Société -- Société
COALESCE(soc.Nom, 'Sans société') as societe, COALESCE(soc.Nom, 'Sans société') as societe,
@@ -5954,6 +6043,31 @@ WHERE CollaborateurADId = @userId
// PUT /api/collaborateurs/:id - Modifier un collaborateur // 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) => { app.put('/collaborateurs/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool); const transaction = new sql.Transaction(pool);
try { try {
@@ -5973,11 +6087,11 @@ WHERE CollaborateurADId = @userId
campusId, campusId,
serviceId, serviceId,
poste, poste,
role,
typeContrat, typeContrat,
dateEntree, dateEntree,
nPlus1Id, nPlus1Id,
nPlus2Id, nPlus2Id
description
} = req.body; } = req.body;
console.log('Modification collaborateur', id, req.body); console.log('Modification collaborateur', id, req.body);
@@ -6001,10 +6115,10 @@ WHERE CollaborateurADId = @userId
.input('societeId', sql.Int, societeId) .input('societeId', sql.Int, societeId)
.input('campusId', sql.Int, campusId) .input('campusId', sql.Int, campusId)
.input('serviceId', sql.Int, serviceId) .input('serviceId', sql.Int, serviceId)
.input('fonction', sql.NVarChar(255), poste)
.input('typeContrat', sql.NVarChar(50), typeContrat) .input('typeContrat', sql.NVarChar(50), typeContrat)
.input('dateEntree', sql.Date, dateEntree || null) .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(` .query(`
UPDATE CollaborateurAD UPDATE CollaborateurAD
SET nom = @nom, SET nom = @nom,
@@ -6013,10 +6127,10 @@ WHERE CollaborateurADId = @userId
SocieteId = @societeId, SocieteId = @societeId,
CampusId = @campusId, CampusId = @campusId,
ServiceId = @serviceId, ServiceId = @serviceId,
fonction = @fonction,
TypeContrat = @typeContrat, TypeContrat = @typeContrat,
DateEntree = @dateEntree, DateEntree = @dateEntree,
description = @description description = @description,
role = @role
WHERE id = @id WHERE id = @id
`); `);
+79
View File
@@ -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>
);
}
-3
View File
@@ -54,9 +54,6 @@ const AuthMicrosoft = () => {
// 🔑 Envoie accessToken au backend // 🔑 Envoie accessToken au backend
const authenticateWithBackend = async (accessToken: string) => { const authenticateWithBackend = async (accessToken: string) => {
try { 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', { const response = await fetch('/api/login-dev', {
method: 'POST', method: 'POST',
headers: { headers: {
+26 -1
View File
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle,
Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList, Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList,
FileText, ChevronLeft, ChevronRight FileText, ChevronLeft, ChevronRight
} from "lucide-react"; } from "lucide-react";
@@ -55,6 +55,8 @@ interface Collaborateur {
campusId: number | null; campusId: number | null;
societe: string; societe: string;
societeId: number | null; societeId: number | null;
dateEntree: string | null;
nPlus1Id: number | null;
} }
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -184,6 +186,8 @@ const Dashboard = () => {
campusId: c.campusId ?? null, campusId: c.campusId ?? null,
societe: c.societe || "", societe: c.societe || "",
societeId: c.societeId ?? null, 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)))]; 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 ──────────────────────────────────────────────── // ── Cartes stats & actions ────────────────────────────────────────────────
const statsCards = [ const statsCards = [
{ title: "Demandes en attente", value: stats.enAttente, icon: Clock, color: "text-warning", bg: "bg-warning/10", action: () => navigate("/validation") }, { 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"> <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 */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statsCards.map((stat, i) => ( {statsCards.map((stat, i) => (
+31 -37
View File
@@ -64,7 +64,6 @@ interface SoldesALaDate {
}>; }>;
} }
// ⭐ Interface prévisionnel (identique à SaisieManuelle)
interface Previsionnel { interface Previsionnel {
mode: string; mode: string;
isAnticipe: boolean; isAnticipe: boolean;
@@ -371,7 +370,7 @@ const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps)
}; };
// ===================================================== // =====================================================
// PANNEAU PRÉVISIONNEL (réutilisé depuis SaisieManuelle) // PANNEAU PRÉVISIONNEL
// ===================================================== // =====================================================
interface PrevisonnnelPanelProps { interface PrevisonnnelPanelProps {
@@ -400,10 +399,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
if (!previsionnel) return null; if (!previsionnel) return null;
const estOk = previsionnel.suffisantCP && previsionnel.suffisantRTT; 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 showCP = previsionnel.totalCPDisponible > 0 || previsionnel.cpNSolde > 0;
const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0; const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0;
@@ -429,8 +424,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* CP */}
{/* CP */}
{showCP && ( {showCP && (
<div className="rounded-lg p-3 border bg-white border-gray-200"> <div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -441,7 +434,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
<div className="space-y-1 text-xs text-gray-600"> <div className="space-y-1 text-xs text-gray-600">
{previsionnel.apresBasculement ? ( {previsionnel.apresBasculement ? (
// ✅ APRÈS LE 1er JUIN : ancien CP N (devenu N-1) + nouveau CP N depuis juin
<> <>
{previsionnel.cpN1Reporte > 0 && ( {previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between"> <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 && ( {previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
@@ -503,7 +494,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
)} )}
{/* RTT */}
{showRTT && ( {showRTT && (
<div className="rounded-lg p-3 border bg-white border-gray-200"> <div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -565,22 +555,23 @@ const GestionCompteurs = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false);
// Dialog consultation soldes à une date
const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false); const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false);
const [selectedCollaborateurId, setSelectedCollaborateurId] = useState<number | null>(null); const [selectedCollaborateurId, setSelectedCollaborateurId] = useState<number | null>(null);
const [selectedEmployeName, setSelectedEmployeName] = useState<string>(""); const [selectedEmployeName, setSelectedEmployeName] = useState<string>("");
const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]); const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]);
// ⭐ État prévisionnel
const [previsionnel, setPrevisionnel] = useState<Previsionnel | null>(null); const [previsionnel, setPrevisionnel] = useState<Previsionnel | null>(null);
const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false); const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false);
// ✅ CORRECTION : currentYear + 1 = 2027 = année en cours après bascule
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const anneeEnCours = currentYear + 1; // 2027
const anneeN1 = currentYear; // 2026
const [editForm, setEditForm] = useState({ total: 0, solde: 0 }); const [editForm, setEditForm] = useState({ total: 0, solde: 0 });
useEffect(() => { chargerCompteurs(); }, []); useEffect(() => { chargerCompteurs(); }, []);
// ⭐ Recharger le prévisionnel quand la date ou le collaborateur change
useEffect(() => { useEffect(() => {
if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) { if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) {
chargerPrevisionnel(); chargerPrevisionnel();
@@ -594,7 +585,6 @@ const GestionCompteurs = () => {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
}; };
// ⭐ Chargement du prévisionnel
const chargerPrevisionnel = useCallback(async () => { const chargerPrevisionnel = useCallback(async () => {
if (!selectedCollaborateurId || !dateConsultation) return; if (!selectedCollaborateurId || !dateConsultation) return;
@@ -792,7 +782,8 @@ const GestionCompteurs = () => {
<SelectItem value="all">Toutes les années</SelectItem> <SelectItem value="all">Toutes les années</SelectItem>
{annees.map(a => ( {annees.map(a => (
<SelectItem key={a} value={a.toString()}> <SelectItem key={a} value={a.toString()}>
{a} {a === currentYear && '(en cours)'} {/* ✅ CORRECTION : En cours = 2027 */}
{a} {a === anneeEnCours && '(en cours)'}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -868,19 +859,28 @@ const GestionCompteurs = () => {
{compteur.typeConge} {compteur.typeConge}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<span className="font-medium">{compteur.annee}</span> <span className="font-medium">{compteur.annee}</span>
{compteur.annee === currentYear && ( {/* CP 2027 = En cours */}
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge> {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>
{compteur.annee === currentYear - 1 && ( )}
<Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge> {/* CP 2026 = N-1 */}
)} {compteur.annee === anneeN1 && compteur.typeConge === 'Congé payé' && (
</div> <Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge>
</TableCell> )}
{/* 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 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"> <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'}`}> <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} {compteur.solde}
@@ -893,7 +893,6 @@ const GestionCompteurs = () => {
title="Modifier le compteur"> title="Modifier le compteur">
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={() => ouvrirConsultationSoldes(compteur)} onClick={() => ouvrirConsultationSoldes(compteur)}
title="Voir les soldes à une date spécifique" title="Voir les soldes à une date spécifique"
@@ -912,9 +911,7 @@ const GestionCompteurs = () => {
</Card> </Card>
</main> </main>
{/* DIALOG CONSULTATION SOLDES */}
{/* ⭐ DIALOG CONSULTATION SOLDES À UNE DATE + PRÉVISIONNEL */}
<Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}> <Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
@@ -928,7 +925,6 @@ const GestionCompteurs = () => {
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{/* Sélecteur de date */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dateConsultation">Date de consultation</Label> <Label htmlFor="dateConsultation">Date de consultation</Label>
<Input <Input
@@ -942,7 +938,6 @@ const GestionCompteurs = () => {
</p> </p>
</div> </div>
{/* ⭐ Panneau prévisionnel */}
{selectedCollaborateurId && dateConsultation && ( {selectedCollaborateurId && dateConsultation && (
<PrevisonnnelPanel <PrevisonnnelPanel
collaborateurId={selectedCollaborateurId} collaborateurId={selectedCollaborateurId}
@@ -952,7 +947,6 @@ const GestionCompteurs = () => {
/> />
)} )}
{/* Snapshot historique (dates passées uniquement) */}
{selectedCollaborateurId && dateConsultation && {selectedCollaborateurId && dateConsultation &&
dateConsultation <= new Date().toISOString().split('T')[0] && ( dateConsultation <= new Date().toISOString().split('T')[0] && (
<SnapshotDateDisplay <SnapshotDateDisplay
@@ -973,7 +967,7 @@ const GestionCompteurs = () => {
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}> <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Modifier le compteur (V2)</DialogTitle> <DialogTitle>Modifier le compteur </DialogTitle>
<DialogDescription> <DialogDescription>
{selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`} {selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`}
</DialogDescription> </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 { interface SnapshotDateDisplayProps {
+139 -19
View File
@@ -33,6 +33,14 @@ interface Previsionnel {
suffisant: boolean; suffisantCP: boolean; suffisantRTT: boolean; suffisant: boolean; suffisantCP: boolean; suffisantRTT: boolean;
deficitCP: number; deficitRTT: number; message: string; deficitCP: number; deficitRTT: number; message: string;
} }
interface DoublonDemande {
id: number;
dateDebut: string;
dateFin: string;
statut: string;
nombreJours: number;
types: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const calcWorkingDays = ( const calcWorkingDays = (
@@ -417,6 +425,8 @@ const ImpactPreview = ({
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
const SaisieManuelle = () => { const SaisieManuelle = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [doublonsInfo, setDoublonsInfo] = useState<Record<string, DoublonDemande[]>>({});
const [isCheckingDoublon, setIsCheckingDoublon] = useState(false);
const [employes, setEmployes] = useState<Employe[]>([]); const [employes, setEmployes] = useState<Employe[]>([]);
const [employesFiltres, setEmployesFiltres] = useState<Employe[]>([]); const [employesFiltres, setEmployesFiltres] = useState<Employe[]>([]);
@@ -456,6 +466,40 @@ const SaisieManuelle = () => {
]).then(([a, b]) => setPublicHolidays({ ...a, ...b })).catch(() => { }); ]).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(() => { useEffect(() => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const h = { Authorization: `Bearer ${token}` }; const h = { Authorization: `Bearer ${token}` };
@@ -568,17 +612,30 @@ const SaisieManuelle = () => {
return t?.nom.toLowerCase().includes("récup") && l.nombreJours > 0; 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 () => { const handleSubmit = async () => {
setError(""); setError("");
if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) { if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) {
setError("Veuillez sélectionner un collaborateur et remplir les dates."); return; 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); const lignesValides = lignesConge.filter(l => l.typeId > 0 && l.nombreJours > 0);
if (!lignesValides.length) { if (!lignesValides.length) {
setError("Ajoutez au moins un type de congé avec un nombre de jours."); return; setError("Ajoutez au moins un type de congé avec un nombre de jours."); return;
} }
setLoading(true); setLoading(true);
let dernierMessageErreur = ""; // ⭐ Capture le dernier message d'erreur précis
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
let ok = 0, ko = 0; let ok = 0, ko = 0;
@@ -606,20 +663,35 @@ const SaisieManuelle = () => {
body: JSON.stringify({ jours: jJPOSF }), body: JSON.stringify({ jours: jJPOSF }),
}); });
} }
} else ko++; } else {
} catch { ko++; } 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) { if (ok > 0) {
toast.success(`${ok} demande(s) enregistrée(s) et validée(s)`, { toast.success(`${ok} demande(s) enregistrée(s) et validée(s)`, {
description: ko > 0 ? `${ko} erreur(s)` : undefined, description: ko > 0 ? `${ko} erreur(s)` : undefined,
}); });
navigate("/dashboard"); 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) { } catch (e: any) {
setError(e.message || "Erreur lors de l'enregistrement"); setError(e.message || "Une erreur est survenue");
} finally { setLoading(false); } } finally { setLoading(false); }
}; };
// ═════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════
// RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte) // RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte)
// ═════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════
@@ -764,14 +836,21 @@ const SaisieManuelle = () => {
{employesSelectionnes.length > 0 && ( {employesSelectionnes.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-3 bg-gray-50 border border-gray-200 rounded-lg"> <div className="flex flex-wrap gap-1.5 p-3 bg-gray-50 border border-gray-200 rounded-lg">
{employesSelectionnes.map(e => ( {employesSelectionnes.map(e => {
<Badge key={e.id} variant="secondary" className="px-2.5 py-1 text-sm"> const aUnDoublon = !!doublonsInfo[e.id.toString()];
{e.nom} {e.prenom} return (
<button type="button" onClick={() => toggleEmploye(e.id.toString())} className="ml-1.5 hover:text-red-500"> <Badge
<X className="w-3 h-3" /> key={e.id}
</button> variant={aUnDoublon ? "destructive" : "secondary"}
</Badge> 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> </div>
)} )}
@@ -866,6 +945,44 @@ const SaisieManuelle = () => {
)} )}
</div> </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>
</div> </div>
@@ -969,13 +1086,16 @@ const SaisieManuelle = () => {
Annuler Annuler
</button> </button>
<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" 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" {loading ? "Enregistrement"
: previsionnel && !previsionnel.suffisant : nbDoublons > 0
? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</> ? <><AlertCircle className="w-4 h-4" /> Doublon détecté</>
: <><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> </button>
</div> </div>
</div> </div>
@@ -1112,4 +1232,4 @@ const SaisieManuelle = () => {
); );
}; };
export default SaisieManuelle; export default SaisieManuelle;
+327 -3
View File
@@ -4,11 +4,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; 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 { useNavigate } from "react-router-dom";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { interface Collaborateur {
id: number; id: number;
@@ -22,9 +31,9 @@ interface Collaborateur {
service: string; service: string;
serviceId: number | null; serviceId: number | null;
poste: string; poste: string;
role: string;
typeContrat: string; typeContrat: string;
dateEntree: string; dateEntree: string;
description: string;
soldeCPN: number; soldeCPN: number;
soldeCPNMoins1: number; soldeCPNMoins1: number;
soldeRTT: number; soldeRTT: number;
@@ -53,6 +62,41 @@ interface Service {
nom: string; 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 Teams = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -69,10 +113,95 @@ const Teams = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set()); 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(() => { useEffect(() => {
chargerDonnees(); 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 () => { const chargerDonnees = async () => {
setLoading(true); setLoading(true);
try { 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) => { const getTypeContratBadge = (type: string) => {
switch (type) { switch (type) {
case 'forfait_jour': case 'forfait_jour':
@@ -170,6 +316,9 @@ const Teams = () => {
`${a.nom} ${a.prenom}`.localeCompare(`${b.nom} ${b.prenom}`) `${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 // Listes uniques pour les filtres
const societesUniques = [...new Set(collaborateurs.map(c => c.societe).filter(Boolean))].sort(); const societesUniques = [...new Set(collaborateurs.map(c => c.societe).filter(Boolean))].sort();
const campusUniques = [...new Set(collaborateurs.map(c => c.campus).filter(Boolean))].sort(); const campusUniques = [...new Set(collaborateurs.map(c => c.campus).filter(Boolean))].sort();
@@ -205,11 +354,182 @@ const Teams = () => {
</div> </div>
</div> </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>
</div> </div>
</header> </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"> <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 */} {/* Filtres */}
<Card className="shadow-card border-0 mb-8"> <Card className="shadow-card border-0 mb-8">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -308,6 +628,8 @@ const Teams = () => {
<TableHead className="w-8"></TableHead> <TableHead className="w-8"></TableHead>
<TableHead>Nom / Prénom</TableHead> <TableHead>Nom / Prénom</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Poste</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Société</TableHead> <TableHead>Société</TableHead>
<TableHead>Campus</TableHead> <TableHead>Campus</TableHead>
<TableHead>Service</TableHead> <TableHead>Service</TableHead>
@@ -339,6 +661,8 @@ const Teams = () => {
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{collab.email} {collab.email}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{collab.poste || '-'}</TableCell>
<TableCell>{getRoleBadge(collab.role)}</TableCell>
<TableCell>{collab.societe || '-'}</TableCell> <TableCell>{collab.societe || '-'}</TableCell>
<TableCell>{collab.campus || '-'}</TableCell> <TableCell>{collab.campus || '-'}</TableCell>
<TableCell>{collab.service || '-'}</TableCell> <TableCell>{collab.service || '-'}</TableCell>
@@ -368,7 +692,7 @@ const Teams = () => {
{/* Ligne de détails expansible */} {/* Ligne de détails expansible */}
{expandedRows.has(collab.id) && ( {expandedRows.has(collab.id) && (
<TableRow className="bg-muted/30"> <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"> <div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Infos personnelles */} {/* Infos personnelles */}
<div className="space-y-2"> <div className="space-y-2">