version_final_sans_test

This commit is contained in:
2025-09-23 14:54:33 +02:00
parent ad695b82f7
commit 8317094a4c
383 changed files with 51629 additions and 1341 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut } from 'lucide-react';
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
interface TimeEntry {
id: string;
@@ -10,6 +11,27 @@ interface TimeEntry {
hours: number;
description: string;
status: 'pending' | 'approved' | 'rejected';
heure_debut?: string;
heure_fin?: string;
formateur_numero?: number;
formateur_email?: string; // NOUVEAU CHAMP
}
interface FormateurAvecDeclarations {
userPrincipalName: string;
displayName: string;
nom: string;
prenom: string;
campus: string;
poste: string;
nbDeclarations: number;
displayText: string;
}
interface SystemStatus {
operatingMode: string;
hasFormateurEmailColumn: boolean;
canAccessFormateurView: boolean;
}
const RHDashboard: React.FC = () => {
@@ -20,32 +42,225 @@ const RHDashboard: React.FC = () => {
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
});
const { logout } = useAuth();
const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
const [showFormateursList, setShowFormateursList] = useState(false);
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]);
const [loading, setLoading] = useState(true);
const [loadingFormateurs, setLoadingFormateurs] = useState(false);
const [error, setError] = useState<string>('');
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille'];
// Fonction pour normaliser/mapper les noms de campus
const normalizeCampus = (campus: string): string => {
const mapping: { [key: string]: string } = {
// Codes courts vers noms longs
'MRS': 'Marseille',
'mrs': 'Marseille',
'NTE': 'Nantes',
'nte': 'Nantes',
'CGY': 'Cergy',
'cgy': 'Cergy',
'SQY': 'SQY',
'sqy': 'SQY',
'ensqy': 'SQY',
'SQY/CGY': 'SQY/CGY', // Campus multi-sites
'sqy/cgy': 'SQY/CGY',
// Liste des formateurs par campus (pour les statistiques uniquement)
const formateursByCampus = {
'Cergy': [
'Jean Dupont', 'Marie Dubois', 'Pierre Martin', 'Sophie Leroy',
'Antoine Bernard', 'Claire Moreau', 'Nicolas Petit', 'Isabelle Roux'
],
'Nantes': [
'Marie Martin', 'Thomas Durand', 'Julie Blanc', 'François Simon',
'Camille Garnier', 'Olivier Faure', 'Nathalie Girard', 'Julien Morel'
],
'SQY': [
'Pierre Durand', 'Sandrine Lefebvre', 'Maxime Rousseau', 'Émilie Mercier',
'Sébastien Fournier', 'Valérie Bonnet', 'Christophe Lambert', 'Aurélie Muller'
],
'Marseille': [
'Sophie Leroy', 'David Fontaine', 'Laure Chevalier', 'Romain Gauthier',
'Céline Masson', 'Fabrice Dupuis', 'Stéphanie Roy', 'Alexandre Perrin'
]
// Noms complets (au cas où)
'Marseille': 'Marseille',
'Nantes': 'Nantes',
'Cergy': 'Cergy',
'Saint-Quentin-en-Yvelines': 'SQY',
'MARSEILLE': 'Marseille',
'NANTES': 'Nantes',
'CERGY': 'Cergy',
// Fallback
'Non défini': 'Non défini',
'': 'Non défini'
};
return mapping[campus] || campus || 'Non défini';
};
// Fonction inversée pour obtenir le code depuis le label
const getCampusCode = (label: string): string => {
const reverseMapping: { [key: string]: string } = {
'Marseille': 'MRS',
'Nantes': 'NTE',
'Cergy': 'CGY',
'SQY': 'SQY',
'SQY/CGY': 'SQY/CGY'
};
return reverseMapping[label] || label;
};
// Liste des campus pour l'interface (incluant SQY/CGY comme cas spécial)
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille', 'SQY/CGY'];
// Fonction pour générer le hash (même logique que le backend - pour compatibilité)
const generateHashFromEmail = (email: string): number => {
let hash = 0;
for (let i = 0; i < email.length; i++) {
const char = email.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 10000 + 1000;
};
// Fonction pour récupérer le statut du système
const loadSystemStatus = async () => {
try {
const response = await fetch('http://localhost:3002/api/diagnostic');
if (response.ok) {
const data = await response.json();
setSystemStatus(data.systemStatus);
console.log('📊 Statut système:', data.systemStatus);
}
} catch (error) {
console.error('Erreur chargement statut système:', error);
}
};
// Fonction améliorée pour associer les déclarations avec les formateurs
const getFormateurInfo = (declaration: any): { nom: string, prenom: string, campus: string, displayText: string } => {
console.log('🔍 Recherche formateur pour:', declaration);
// NOUVEAU SYSTÈME : Si on a un email, chercher le formateur correspondant
if (declaration.formateur_email || declaration.formateur_email_fk) {
const email = declaration.formateur_email || declaration.formateur_email_fk;
const formateurTrouve = formateursAvecDeclarations.find(f =>
f.userPrincipalName === email
);
if (formateurTrouve) {
console.log(`✅ Formateur trouvé par email: ${email} -> ${formateurTrouve.displayText} (${formateurTrouve.campus})`);
return {
nom: formateurTrouve.nom,
prenom: formateurTrouve.prenom,
campus: formateurTrouve.campus, // Garder le campus original de la vue
displayText: formateurTrouve.displayText
};
}
}
// FALLBACK : Si on a les champs nom/prenom/campus directement dans la déclaration (nouveau système)
if (declaration.nom && declaration.nom !== 'undefined') {
console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`);
return {
nom: declaration.nom,
prenom: declaration.prenom || '',
campus: declaration.campus || declaration.Campus || 'Non défini',
displayText: declaration.formateur_nom_complet || `${declaration.nom} ${declaration.prenom || ''}`.trim()
};
}
// ANCIEN SYSTÈME : Si on a un formateur_numero, essayer de le correspondre
if (declaration.formateur_numero && formateursAvecDeclarations.length > 0) {
console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`);
// Méthode 1: Chercher par hash d'email
const formateurParHash = formateursAvecDeclarations.find(f => {
if (f.userPrincipalName) {
const hash = generateHashFromEmail(f.userPrincipalName);
return hash === declaration.formateur_numero;
}
return false;
});
if (formateurParHash) {
console.log(`✅ Formateur trouvé par hash: ${declaration.formateur_numero} -> ${formateurParHash.displayText} (${formateurParHash.campus})`);
return {
nom: formateurParHash.nom,
prenom: formateurParHash.prenom,
campus: formateurParHash.campus,
displayText: formateurParHash.displayText
};
}
}
// DERNIER RECOURS : Mapping connu pour les cas difficiles
const knownMappings: { [key: number]: { nom: string, prenom: string, campus: string } } = {
122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' }
};
if (declaration.formateur_numero && knownMappings[declaration.formateur_numero]) {
const known = knownMappings[declaration.formateur_numero];
console.log(`✅ Mapping connu trouvé: ${declaration.formateur_numero} -> ${known.nom} (${known.campus})`);
return {
nom: known.nom,
prenom: known.prenom,
campus: known.campus,
displayText: `${known.nom} ${known.prenom} (${known.campus})`
};
}
// FALLBACK FINAL
const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu';
console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`);
return {
nom: `Formateur ${identifier}`,
prenom: '',
campus: 'Non défini',
displayText: `Formateur ${identifier} (Non défini)`
};
};
// Fonction pour charger TOUS les formateurs (avec et sans déclarations)
const loadFormateursAvecDeclarations = async () => {
try {
setLoadingFormateurs(true);
setError('');
console.log('🔄 Chargement de tous les formateurs...');
// Essayer d'abord de récupérer tous les formateurs depuis la vue
const response = await fetch('http://localhost:3002/api/formateurs-vue');
if (response.ok) {
const data = await response.json();
if (data.success) {
console.log(`${data.formateurs.length} formateurs chargés depuis la vue`);
console.log('📊 Mode serveur:', data.mode);
// Convertir le format et initialiser à 0 déclarations
const tousLesFormateurs = data.formateurs.map((f: any) => ({
...f,
nbDeclarations: 0,
poste: f.poste || ''
}));
setFormateursAvecDeclarations(tousLesFormateurs);
} else {
throw new Error(data.message || 'Erreur lors du chargement des formateurs');
}
} else {
throw new Error(`Erreur HTTP ${response.status}`);
}
} catch (error: any) {
console.error('❌ Erreur chargement formateurs depuis la vue:', error);
// Fallback: essayer avec formateurs-avec-declarations (ancienne méthode)
try {
console.log('🔄 Tentative de fallback avec formateurs-avec-declarations...');
const fallbackResponse = await fetch('http://localhost:3002/api/formateurs-avec-declarations');
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.success) {
setFormateursAvecDeclarations(fallbackData.formateurs);
console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations');
setError(''); // Effacer l'erreur si le fallback fonctionne
}
}
} catch (fallbackError) {
console.error('❌ Fallback échoué aussi:', fallbackError);
setError(`Impossible de charger les formateurs: ${error.message}`);
}
} finally {
setLoadingFormateurs(false);
}
};
// Charger les déclarations depuis votre API
@@ -53,30 +268,47 @@ const RHDashboard: React.FC = () => {
try {
setLoading(true);
setError('');
console.log('🔄 Chargement des déclarations...');
const response = await fetch('http://localhost:3002/api/get_declarations');
if (!response.ok) {
throw new Error('Erreur lors du chargement des déclarations');
throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`);
}
const data = await response.json();
console.log('Données reçues de l\'API:', data);
console.log('📊 Données déclarations reçues:', data.length);
console.log('📊 Exemple de déclaration:', data[0]);
// Convertir les données de l'API au format TimeEntry
const convertedEntries: TimeEntry[] = data.map((d: any) => ({
id: d.id.toString(),
formateur: `${d.prenom || ''} ${d.nom || ''}`.trim() || 'Formateur inconnu',
campus: d.campus || 'Non défini',
date: d.date.split('T')[0],
type: d.activityType,
hours: d.duree,
description: d.description || '',
status: d.status || 'pending'
}));
// Convertir les données (avec normalisation des campus)
const convertedEntries: TimeEntry[] = data.map((d: any) => {
const formateurInfo = getFormateurInfo(d);
return {
id: d.id.toString(),
formateur: formateurInfo.displayText,
campus: normalizeCampus(formateurInfo.campus), // Normaliser le campus
date: d.date.split('T')[0],
type: d.activityType,
hours: d.duree,
description: d.description || '',
status: d.status || 'pending',
heure_debut: d.heure_debut || null,
heure_fin: d.heure_fin || null,
formateur_numero: d.formateur_numero,
formateur_email: d.formateur_email || d.formateur_email_fk
};
});
setTimeEntries(convertedEntries);
console.log(`${convertedEntries.length} déclarations traitées`);
// Log des formateurs uniques pour debug
const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))];
console.log(`📊 ${formateursUniques.length} formateurs uniques dans les déclarations:`, formateursUniques.slice(0, 5));
} catch (err: any) {
console.error('Erreur lors du chargement:', err);
console.error('Erreur lors du chargement des déclarations:', err);
setError(err.message);
} finally {
setLoading(false);
@@ -85,34 +317,153 @@ const RHDashboard: React.FC = () => {
// Charger les données au démarrage
useEffect(() => {
loadDeclarations();
const loadData = async () => {
await loadSystemStatus();
await loadFormateursAvecDeclarations();
await loadDeclarations();
// Debug: Afficher tous les campus uniques trouvés dans les données
setTimeout(() => {
const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean);
const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean);
console.log('🏢 Campus trouvés dans les formateurs:', campusUniques);
console.log('🏢 Campus trouvés dans les déclarations:', campusDeclarations);
console.log('🏢 Tous les campus uniques:', [...new Set([...campusUniques, ...campusDeclarations])]);
}, 2000);
};
loadData();
}, []);
// Re-traiter les déclarations quand les formateurs sont chargés ET calculer les nombres de déclarations
useEffect(() => {
if (formateursAvecDeclarations.length > 0) {
console.log('🔄 Re-traitement des déclarations avec les nouveaux formateurs...');
// Si on a déjà des déclarations, les retraiter
if (timeEntries.length > 0) {
const updatedEntries = timeEntries.map(entry => {
const originalDeclaration = {
formateur_numero: entry.formateur_numero,
formateur_email: entry.formateur_email
};
const formateurInfo = getFormateurInfo(originalDeclaration);
return {
...entry,
formateur: formateurInfo.displayText,
campus: formateurInfo.campus
};
});
setTimeEntries(updatedEntries);
}
// Calculer le nombre de déclarations pour chaque formateur
const formateursAvecCompte = formateursAvecDeclarations.map(formateur => {
const nbDeclarations = timeEntries.filter(entry => {
// Correspondance par email
if (entry.formateur_email === formateur.userPrincipalName) {
return true;
}
// Correspondance par hash
if (entry.formateur_numero && formateur.userPrincipalName) {
const hash = generateHashFromEmail(formateur.userPrincipalName);
return hash === entry.formateur_numero;
}
// Correspondance par nom affiché
const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim();
return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText;
}).length;
return {
...formateur,
nbDeclarations
};
});
setFormateursAvecDeclarations(formateursAvecCompte);
}
}, [timeEntries.length]); // Déclenché quand le nombre de déclarations change
// Réinitialiser le filtre formateur quand on change de campus
useEffect(() => {
// Si un formateur est sélectionné et qu'on change de campus
if (selectedFormateur !== 'all') {
// Vérifier si le formateur sélectionné existe encore dans le campus filtré
const formateurExists = formateursAvecDeclarations.some(formateur => {
const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus;
return campusMatch && formateur.displayText === selectedFormateur;
});
// Si le formateur n'existe pas dans le nouveau campus, le réinitialiser
if (!formateurExists) {
console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`);
setSelectedFormateur('all');
}
}
}, [selectedCampus, formateursAvecDeclarations]);
// Fonction de déconnexion
const handleLogout = () => {
// Nettoyer le localStorage
localStorage.removeItem('token');
localStorage.removeItem('o365_token');
localStorage.removeItem('user');
// Rediriger vers la page de login
logout();
window.location.href = '/login';
};
// Filtrage des données
// Fonction de rafraîchissement complète
const handleRefresh = async () => {
await loadSystemStatus();
await loadFormateursAvecDeclarations();
await loadDeclarations();
};
// Filtrage des données (avec normalisation des campus)
const filteredEntries = timeEntries.filter(entry => {
const campusMatch = selectedCampus === 'all' || entry.campus === selectedCampus;
const entryNormalizedCampus = normalizeCampus(entry.campus);
const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus;
const formateurMatch = selectedFormateur === 'all' || entry.formateur === selectedFormateur;
const monthMatch = entry.date.startsWith(selectedMonth);
return campusMatch && formateurMatch && monthMatch;
});
// Liste des formateurs uniques à partir des vraies données
const formateurs = Array.from(new Set(timeEntries.map(entry => entry.formateur))).sort();
// Générer la liste des formateurs filtrés par campus (avec déduplication)
const formateursUniques = formateursAvecDeclarations
.filter(formateur => {
// Si "tous les campus" est sélectionné, afficher tous les formateurs
if (selectedCampus === 'all') return true;
// Normaliser le campus du formateur et comparer avec le campus sélectionné
const formateurCampusNormalized = normalizeCampus(formateur.campus);
return formateurCampusNormalized === selectedCampus;
})
.reduce((acc: any[], formateur) => {
// Déduplicquer par email (userPrincipalName)
const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName);
if (!existing) {
acc.push({
displayText: formateur.displayText,
nbDeclarations: formateur.nbDeclarations || 0,
userPrincipalName: formateur.userPrincipalName,
campus: normalizeCampus(formateur.campus) // Normaliser le campus pour l'affichage
});
} else {
// Si le formateur existe déjà, additionner les déclarations
existing.nbDeclarations += (formateur.nbDeclarations || 0);
}
return acc;
}, [])
.sort((a, b) => a.displayText.localeCompare(b.displayText));
// Statistiques par campus
// Statistiques par campus (avec normalisation)
const getStatsForCampus = (campus: string) => {
const campusEntries = timeEntries.filter(entry => entry.campus === campus);
const campusEntries = timeEntries.filter(entry => {
const entryNormalizedCampus = normalizeCampus(entry.campus);
return entryNormalizedCampus === campus;
});
const totalHours = campusEntries.reduce((sum, entry) => sum + entry.hours, 0);
const preparationHours = campusEntries
.filter(entry => entry.type === 'preparation')
@@ -125,30 +476,48 @@ const RHDashboard: React.FC = () => {
};
const handleExport = () => {
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Description', 'Statut'];
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Heure Début', 'Heure Fin', 'Description'];
const csvData = filteredEntries.map(entry => [
new Date(entry.date).toLocaleDateString('fr-FR'),
entry.formateur,
entry.campus,
entry.formateur || 'Non défini',
entry.campus || 'Non défini',
entry.type === 'preparation' ? 'Préparation' : 'Correction',
entry.hours.toString(),
entry.description,
entry.status === 'approved' ? 'Approuvé' : entry.status === 'pending' ? 'En attente' : 'Rejeté'
entry.heure_debut || 'Non défini',
entry.heure_fin || 'Non défini',
(entry.description || '').replace(/[\r\n]+/g, ' ').trim()
]);
const csvContent = [csvHeaders, ...csvData]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
// Fonction pour nettoyer les valeurs (enlever guillemets et point-virgules problématiques)
const cleanCsvValue = (value) => {
return String(value || '')
.replace(/"/g, '""') // Doubler les guillemets
.replace(/;/g, ','); // Remplacer ; par , dans les données
};
// Utiliser le point-virgule comme séparateur pour Excel français
const csvContent = '\uFEFF' + [csvHeaders, ...csvData]
.map(row => row.map(cell => cleanCsvValue(cell)).join(';')) // Point-virgule ici !
.join('\r\n'); // Utiliser \r\n pour Windows
const blob = new Blob([csvContent], {
type: 'text/csv;charset=utf-8;'
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `declarations_heures_${selectedMonth}.csv`);
link.style.visibility = 'hidden';
link.href = url;
link.download = `declarations_heures_${selectedMonth}.csv`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 100);
};
const formatDate = (dateString: string) => {
@@ -159,41 +528,35 @@ const RHDashboard: React.FC = () => {
});
};
const getStatusBadge = (status: string) => {
const statusConfig = {
approved: { bg: 'bg-green-100', text: 'text-green-800', label: 'Approuvé' },
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'En attente' },
rejected: { bg: 'bg-red-100', text: 'text-red-800', label: 'Rejeté' }
};
const formatTime = (timeString: string | null | undefined) => {
if (!timeString) return '-';
const config = statusConfig[status as keyof typeof statusConfig];
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
{config.label}
</span>
);
};
const handleStatusChange = async (entryId: string, newStatus: 'approved' | 'rejected') => {
try {
// Vous devrez créer cette route dans votre backend
const response = await fetch(`http://localhost:3002/api/declarations/${entryId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!response.ok) {
throw new Error('Erreur lors de la mise à jour du statut');
if (timeString.match(/^\d{2}:\d{2}$/)) {
return timeString;
}
// Recharger les données après modification
await loadDeclarations();
if (timeString.match(/^\d{2}:\d{2}:\d{2}\.\d+$/)) {
return timeString.substring(0, 5);
}
console.log(`Statut mis à jour pour l'entrée ${entryId}: ${newStatus}`);
} catch (error: any) {
console.error('Erreur lors du changement de statut:', error);
setError(`Erreur lors du changement de statut: ${error.message}`);
if (timeString.match(/^\d{2}:\d{2}:\d{2}$/)) {
return timeString.substring(0, 5);
}
if (timeString.includes('T')) {
const timePart = timeString.split('T')[1];
if (timePart) {
const hourMin = timePart.substring(0, 5);
if (hourMin.match(/^\d{2}:\d{2}$/)) {
return hourMin;
}
}
}
return timeString;
} catch (error) {
return '-';
}
};
@@ -206,21 +569,21 @@ const RHDashboard: React.FC = () => {
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-3">
<Users className="text-blue-600" />
Vue RH - GTF
</h1>
<p className="text-gray-600 mt-2">
Gestion et suivi des déclarations
Gestion et suivi des déclarations des formateurs
</p>
</div>
{/* Boutons d'action */}
<div className="flex items-center gap-3">
<button
onClick={loadDeclarations}
disabled={loading}
onClick={handleRefresh}
disabled={loading || loadingFormateurs}
className="flex items-center gap-2 px-4 py-2 bg-blue-50 hover:bg-blue-100 text-blue-600 font-medium rounded-lg transition-colors border border-blue-200 hover:border-blue-300 disabled:opacity-50"
>
<Calendar className="h-5 w-5" />
{loading ? 'Chargement...' : 'Actualiser'}
<RefreshCw className={`h-5 w-5 ${(loading || loadingFormateurs) ? 'animate-spin' : ''}`} />
{loading || loadingFormateurs ? 'Chargement...' : 'Actualiser'}
</button>
<button
@@ -241,21 +604,20 @@ const RHDashboard: React.FC = () => {
<span className="text-red-800 font-medium">Erreur de chargement</span>
</div>
<p className="text-red-600 mt-2">{error}</p>
<p className="text-red-500 text-sm mt-1">
Assurez-vous que votre serveur backend est démarré sur http://localhost:3002
</p>
</div>
)}
{/* Indicateur de chargement */}
{loading && (
{(loading || loadingFormateurs) && (
<div className="bg-white rounded-xl shadow-lg p-8 mb-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des déclarations...</p>
<p className="text-gray-600">
{loadingFormateurs ? 'Chargement des formateurs...' : 'Chargement des déclarations...'}
</p>
</div>
)}
{!loading && (
{!loading && !loadingFormateurs && (
<>
{/* Filtres */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
@@ -283,7 +645,7 @@ const RHDashboard: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Formateur
Formateur ({formateursUniques.length} total, {formateursUniques.filter(f => f.nbDeclarations > 0).length} avec déclarations)
</label>
<select
value={selectedFormateur}
@@ -291,8 +653,13 @@ const RHDashboard: React.FC = () => {
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Tous les formateurs</option>
{formateurs.map(formateur => (
<option key={formateur} value={formateur}>{formateur}</option>
{formateursUniques.map(formateur => (
<option
key={`${formateur.userPrincipalName || formateur.displayText}-${formateur.campus}`}
value={formateur.displayText}
>
{formateur.displayText} ({formateur.nbDeclarations} décl.)
</option>
))}
</select>
</div>
@@ -398,15 +765,15 @@ const RHDashboard: React.FC = () => {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heures
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heure Début
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heure Fin
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@@ -449,34 +816,21 @@ const RHDashboard: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{entry.hours}h
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div className="flex items-center gap-1">
<Clock size={14} className="text-green-500" />
{formatTime(entry.heure_debut)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div className="flex items-center gap-1">
<Clock size={14} className="text-red-500" />
{formatTime(entry.heure_fin)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{entry.description}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(entry.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{entry.status === 'pending' ? (
<div className="flex gap-2">
<button
onClick={() => handleStatusChange(entry.id, 'approved')}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 rounded text-xs transition-colors"
>
Approuver
</button>
<button
onClick={() => handleStatusChange(entry.id, 'rejected')}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-xs transition-colors"
>
Rejeter
</button>
</div>
) : (
<span className="text-gray-400 text-xs">
{entry.status === 'approved' ? 'Approuvé' : 'Rejeté'}
</span>
)}
</td>
</tr>
))
)}