848 lines
42 KiB
TypeScript
848 lines
42 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react';
|
|
import { useAuth } from '../context/AuthContext';
|
|
|
|
interface TimeEntry {
|
|
id: string;
|
|
formateur: string;
|
|
campus: string;
|
|
date: string;
|
|
type: 'preparation' | 'correction';
|
|
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 = () => {
|
|
const [selectedCampus, setSelectedCampus] = useState<string>('all');
|
|
const [selectedMonth, setSelectedMonth] = useState<string>(() => {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
return `${year}-${month}`;
|
|
});
|
|
const { logout } = useAuth();
|
|
const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
|
|
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);
|
|
|
|
// 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',
|
|
|
|
// 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
|
|
const loadDeclarations = async () => {
|
|
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 HTTP ${response.status} lors du chargement des déclarations`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('📊 Données déclarations reçues:', data.length);
|
|
console.log('📊 Exemple de déclaration:', data[0]);
|
|
|
|
// 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 des déclarations:', err);
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Charger les données au démarrage
|
|
useEffect(() => {
|
|
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 = () => {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('o365_token');
|
|
localStorage.removeItem('user');
|
|
logout();
|
|
window.location.href = '/login';
|
|
};
|
|
|
|
// 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 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;
|
|
});
|
|
|
|
// 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 (avec normalisation)
|
|
const getStatsForCampus = (campus: string) => {
|
|
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')
|
|
.reduce((sum, entry) => sum + entry.hours, 0);
|
|
const correctionHours = campusEntries
|
|
.filter(entry => entry.type === 'correction')
|
|
.reduce((sum, entry) => sum + entry.hours, 0);
|
|
|
|
return { totalHours, preparationHours, correctionHours };
|
|
};
|
|
|
|
const handleExport = () => {
|
|
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 || 'Non défini',
|
|
entry.campus || 'Non défini',
|
|
entry.type === 'preparation' ? 'Préparation' : 'Correction',
|
|
entry.hours.toString(),
|
|
entry.heure_debut || 'Non défini',
|
|
entry.heure_fin || 'Non défini',
|
|
(entry.description || '').replace(/[\r\n]+/g, ' ').trim()
|
|
]);
|
|
|
|
// 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 link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
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) => {
|
|
return new Date(dateString).toLocaleDateString('fr-FR', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long'
|
|
});
|
|
};
|
|
|
|
const formatTime = (timeString: string | null | undefined) => {
|
|
if (!timeString) return '-';
|
|
|
|
try {
|
|
if (timeString.match(/^\d{2}:\d{2}$/)) {
|
|
return timeString;
|
|
}
|
|
|
|
if (timeString.match(/^\d{2}:\d{2}:\d{2}\.\d+$/)) {
|
|
return timeString.substring(0, 5);
|
|
}
|
|
|
|
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 '-';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-4">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-8 flex justify-between items-center">
|
|
<div>
|
|
<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 des formateurs
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
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"
|
|
>
|
|
<RefreshCw className={`h-5 w-5 ${(loading || loadingFormateurs) ? 'animate-spin' : ''}`} />
|
|
{loading || loadingFormateurs ? 'Chargement...' : 'Actualiser'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-50 hover:bg-red-100 text-red-600 font-medium rounded-lg transition-colors border border-red-200 hover:border-red-300"
|
|
>
|
|
<LogOut className="h-5 w-5" />
|
|
<span>Déconnexion</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message d'erreur */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded-full bg-red-500"></div>
|
|
<span className="text-red-800 font-medium">Erreur de chargement</span>
|
|
</div>
|
|
<p className="text-red-600 mt-2">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Indicateur de chargement */}
|
|
{(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">
|
|
{loadingFormateurs ? 'Chargement des formateurs...' : 'Chargement des déclarations...'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !loadingFormateurs && (
|
|
<>
|
|
{/* Filtres */}
|
|
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Filter className="text-gray-600" size={20} />
|
|
<h2 className="text-lg font-semibold text-gray-800">Filtres</h2>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Campus
|
|
</label>
|
|
<select
|
|
value={selectedCampus}
|
|
onChange={(e) => setSelectedCampus(e.target.value)}
|
|
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 campus</option>
|
|
{campuses.map(campus => (
|
|
<option key={campus} value={campus}>{campus}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Formateur ({formateursUniques.length} total, {formateursUniques.filter(f => f.nbDeclarations > 0).length} avec déclarations)
|
|
</label>
|
|
<select
|
|
value={selectedFormateur}
|
|
onChange={(e) => setSelectedFormateur(e.target.value)}
|
|
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>
|
|
{formateursUniques.map(formateur => (
|
|
<option
|
|
key={`${formateur.userPrincipalName || formateur.displayText}-${formateur.campus}`}
|
|
value={formateur.displayText}
|
|
>
|
|
{formateur.displayText} ({formateur.nbDeclarations} décl.)
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Mois
|
|
</label>
|
|
<input
|
|
type="month"
|
|
value={selectedMonth}
|
|
onChange={(e) => setSelectedMonth(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<button
|
|
onClick={handleExport}
|
|
className="w-full bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
|
>
|
|
<Download size={20} />
|
|
Exporter CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistiques par campus */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
{campuses.map(campus => {
|
|
const stats = getStatsForCampus(campus);
|
|
return (
|
|
<div key={campus} className="bg-white rounded-xl shadow-lg p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-800">{campus}</h3>
|
|
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-gray-600">Total:</span>
|
|
<span className="font-semibold text-gray-800">{stats.totalHours}h</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-blue-600">Préparation:</span>
|
|
<span className="font-medium text-blue-600">{stats.preparationHours}h</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-orange-600">Correction:</span>
|
|
<span className="font-medium text-orange-600">{stats.correctionHours}h</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Résumé des résultats */}
|
|
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-800">
|
|
Résultats filtrés
|
|
</h3>
|
|
<p className="text-gray-600">
|
|
{filteredEntries.length} déclaration(s) trouvée(s)
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-2xl font-bold text-blue-600">
|
|
{filteredEntries.reduce((sum, entry) => sum + entry.hours, 0)}h
|
|
</p>
|
|
<p className="text-sm text-gray-600">Total des heures</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tableau des déclarations */}
|
|
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
<div className="p-6 border-b border-gray-200">
|
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
|
<Calendar className="text-blue-600" />
|
|
Déclarations d'heures
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Date
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Formateur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Campus
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredEntries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="px-6 py-8 text-center text-gray-500">
|
|
{timeEntries.length === 0
|
|
? "Aucune déclaration dans la base de données"
|
|
: "Aucune déclaration trouvée pour les critères sélectionnés"
|
|
}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredEntries.map((entry) => (
|
|
<tr key={entry.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{formatDate(entry.date)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
{entry.formateur}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{entry.campus}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${entry.type === 'preparation'
|
|
? 'bg-blue-100 text-blue-800'
|
|
: 'bg-orange-100 text-orange-800'
|
|
}`}>
|
|
<div className="flex items-center gap-1">
|
|
{entry.type === 'preparation' ? (
|
|
<FileText size={12} />
|
|
) : (
|
|
<Clock size={12} />
|
|
)}
|
|
{entry.type === 'preparation' ? 'Préparation' : 'Correction'}
|
|
</div>
|
|
</span>
|
|
</td>
|
|
<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>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RHDashboard; |