version_final_sans_test
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
|
||||
|
||||
|
||||
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure
|
||||
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID
|
||||
redirectUri: "http://localhost:5173"
|
||||
redirectUri: "http://localhost:5174"
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "sessionStorage",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1337
GTFRRH/project/package-lock.json
generated
1337
GTFRRH/project/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,23 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import Login from './components/Login';
|
||||
import RHDashboard from './components/RHDashboard';
|
||||
|
||||
// Composant pour protéger les routes
|
||||
// Composant pour protéger les routes
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthorized } = useAuth();
|
||||
|
||||
console.log('ProtectedRoute - isAuthorized:', isAuthorized);
|
||||
|
||||
return isAuthorized ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
// Composant pour rediriger si déjà connecté
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { isAuthorized } = useAuth();
|
||||
|
||||
console.log('PublicRoute - isAuthorized:', isAuthorized);
|
||||
|
||||
return !isAuthorized ? children : <Navigate to="/dashboard" replace />;
|
||||
};
|
||||
|
||||
@@ -22,7 +25,7 @@ function AppContent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Routes>
|
||||
{/* Route de login - accessible seulement si non connecté */}
|
||||
{/* Route de login - accessible seulement si non connecté */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
@@ -32,7 +35,7 @@ function AppContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Route du dashboard - accessible seulement si connecté */}
|
||||
{/* Route du dashboard - accessible seulement si connecté */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -42,13 +45,13 @@ function AppContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Route par défaut - redirige vers login ou dashboard selon l'état */}
|
||||
{/* Route par défaut - redirige vers login ou dashboard selon l'état */}
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
|
||||
{/* Route catch-all pour les URLs non trouvées */}
|
||||
{/* Route catch-all pour les URLs non trouvées */}
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/login" replace />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Users } from 'lucide-react';
|
||||
@@ -10,69 +10,148 @@ const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const { loginWithO365, isAuthorized } = useAuth();
|
||||
|
||||
// Redirection si déjà connecté
|
||||
// Configuration du backend Node.js
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
// Redirection si déjà connecté
|
||||
useEffect(() => {
|
||||
if (isAuthorized) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [isAuthorized]);
|
||||
}, [isAuthorized, navigate]);
|
||||
|
||||
const handleO365Login = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// 1. Connexion Office 365 via votre contexte existant
|
||||
const success = await loginWithO365();
|
||||
|
||||
if (!success) {
|
||||
setError("Erreur lors de la connexion Office 365");
|
||||
setError("Erreur lors de l'initialisation de la connexion Office 365");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupération du token
|
||||
// Le reste sera géré par l'AuthContext lors du retour de Microsoft
|
||||
// (exchangeCodeForToken + vérification backend)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Erreur O365:', err);
|
||||
setError(err.message || "Erreur lors de la connexion Office 365");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour traiter l'authentification complète (appelée après retour de Microsoft)
|
||||
const completeAuthentication = async () => {
|
||||
try {
|
||||
// 2. Récupération du token et des infos utilisateur
|
||||
const token = localStorage.getItem("o365_token");
|
||||
const userEmail = localStorage.getItem("user_email");
|
||||
|
||||
// Simulation synchronisation groupes
|
||||
const data = { success: true };
|
||||
console.log("Résultat syncGroups :", data);
|
||||
|
||||
if (!data.success) {
|
||||
setError("Erreur de synchronisation des groupes : " + data.message);
|
||||
setIsLoading(false);
|
||||
if (!token || !userEmail) {
|
||||
setError("Token ou email utilisateur manquant");
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirection vers dashboard
|
||||
// 3. Vérification de l'autorisation via votre backend Node.js
|
||||
const authResponse = await fetch(`${BACKEND_URL}/api/check-user-groups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userPrincipalName: userEmail
|
||||
})
|
||||
});
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new Error(`Erreur serveur: ${authResponse.status}`);
|
||||
}
|
||||
|
||||
const authData = await authResponse.json();
|
||||
console.log("Résultat autorisation backend :", authData);
|
||||
|
||||
if (!authData.authorized) {
|
||||
setError(authData.message || "Utilisateur non autorisé");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Stocker les informations utilisateur complémentaires
|
||||
localStorage.setItem("user_data", JSON.stringify(authData.user));
|
||||
localStorage.setItem("user_role", authData.role);
|
||||
localStorage.setItem("local_user_id", authData.localUserId.toString());
|
||||
|
||||
console.log("Utilisateur autorisé :", authData.user);
|
||||
|
||||
// 5. Optionnel : Déclencher une synchronisation initiale si c'est le premier login
|
||||
try {
|
||||
const syncResponse = await fetch(`${BACKEND_URL}/api/initial-sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (syncResponse.ok) {
|
||||
const syncData = await syncResponse.json();
|
||||
console.log("Synchronisation terminée :", syncData);
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.warn("Erreur synchronisation (non bloquante):", syncError);
|
||||
// Ne pas bloquer la connexion pour une erreur de sync
|
||||
}
|
||||
|
||||
// 6. Redirection vers dashboard
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erreur O365:', err);
|
||||
} catch (err: any) {
|
||||
console.error('Erreur lors de la finalisation de l\'authentification:', err);
|
||||
|
||||
if (err.message?.includes('non autorisé') || err.message?.includes('Accès refusé')) {
|
||||
setError('Accès refusé : Vous devez être membre d\'un groupe autorisé dans votre organisation.');
|
||||
// Gestion des erreurs spécifiques
|
||||
if (err.message?.includes('non autorisé') || err.message?.includes('Accès refusé')) {
|
||||
setError('Accès refusé : Vous devez être membre du groupe autorisé dans votre organisation.');
|
||||
} else if (err.message?.includes('AADSTS')) {
|
||||
setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.');
|
||||
} else if (err.message?.includes('fetch')) {
|
||||
setError('Impossible de contacter le serveur. Vérifiez que le backend est démarré.');
|
||||
} else {
|
||||
setError(err.message || "Erreur lors de la connexion Office 365");
|
||||
setError(err.message || "Erreur lors de la finalisation de la connexion");
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Vérifier s'il faut finaliser l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const authToken = localStorage.getItem('o365_token');
|
||||
|
||||
// Si on a un code ET un token (authentification en cours), finaliser
|
||||
if (code && authToken && !isAuthorized) {
|
||||
setIsLoading(true);
|
||||
completeAuthentication().finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [isAuthorized]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
|
||||
{/* En-tête */}
|
||||
{/* En-tête */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#7e5aa2] rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">GTF - Espace RH</h1>
|
||||
|
||||
<p className="text-gray-600 text-sm">
|
||||
Connectez-vous avec votre compte Office 365
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton de connexion Office 365 */}
|
||||
@@ -84,16 +163,11 @@ const Login = () => {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Connexion...</span>
|
||||
<span>Connexion en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.5 12c0-.813-.069-1.613-.2-2.4H12v4.54h6.458c-.28 1.488-1.13 2.75-2.41 3.6v2.988h3.908c2.28-2.1 3.594-5.194 3.594-8.728z" />
|
||||
<path d="M12 24c3.24 0 5.956-1.075 7.944-2.906l-3.908-2.988c-1.075.725-2.456 1.156-4.036 1.156-3.106 0-5.744-2.1-6.681-4.919H1.294v3.081C3.281 21.394 7.344 24 12 24z" />
|
||||
<path d="M5.319 14.343c-.238-.725-.375-1.494-.375-2.343s.138-1.619.375-2.344V6.575H1.294C.469 8.225 0 10.044 0 12s.469 3.775 1.294 5.425l4.025-3.082z" />
|
||||
<path d="M12 4.781c1.75 0 3.319.6 4.556 1.781l3.419-3.419C17.944 1.194 15.231 0 12 0 7.344 0 3.281 2.606 1.294 6.575l4.025 3.081C6.256 6.881 8.894 4.781 12 4.781z" />
|
||||
</svg>
|
||||
|
||||
<span>Se connecter avec Office 365</span>
|
||||
</>
|
||||
)}
|
||||
@@ -106,21 +180,26 @@ const Login = () => {
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-800 font-medium text-sm">
|
||||
{error.includes('Accès refusé') ? 'Accès refusé' : 'Erreur de connexion'}
|
||||
{error.includes('Accès refusé') ? 'Accès refusé' :
|
||||
error.includes('serveur') ? 'Problème de connexion' :
|
||||
'Erreur de connexion'}
|
||||
</p>
|
||||
<p className="text-red-700 text-xs mt-1">{error}</p>
|
||||
{error.includes('groupe autorisé') && (
|
||||
{error.includes('groupe autorisé') && (
|
||||
<p className="text-red-700 text-xs mt-2">
|
||||
Contactez votre administrateur pour être ajouté aux groupes appropriés.
|
||||
Contactez votre administrateur pour être ajouté au groupe GTF.
|
||||
</p>
|
||||
)}
|
||||
{error.includes('serveur') && (
|
||||
<p className="text-red-700 text-xs mt-2">
|
||||
Vérifiez que le serveur backend est démarré sur le port 3002.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext();
|
||||
interface AuthContextType {
|
||||
isAuthorized: boolean;
|
||||
user: any | null;
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
loginWithO365: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
@@ -10,86 +19,232 @@ export const useAuth = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Vérifier l'état d'authentification au chargement
|
||||
// Configuration Microsoft OAuth
|
||||
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
|
||||
const REDIRECT_URI = 'http://localhost:5174';
|
||||
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
|
||||
|
||||
// Vérifier l'état d'authentification au chargement
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token') || localStorage.getItem('o365_token');
|
||||
const userData = localStorage.getItem('user');
|
||||
const token = localStorage.getItem('o365_token');
|
||||
const userData = localStorage.getItem('user_data'); // Changé de 'user' à 'user_data'
|
||||
|
||||
if (token && userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
setIsAuthorized(true);
|
||||
try {
|
||||
setUser(JSON.parse(userData));
|
||||
setIsAuthorized(true);
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing user data:', error);
|
||||
// Nettoyer les données corrompues
|
||||
localStorage.removeItem('o365_token');
|
||||
localStorage.removeItem('user_data');
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si on revient d'une authentification Microsoft
|
||||
checkForAuthCode();
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Connexion classique (email/password)
|
||||
const login = async (email, password) => {
|
||||
// Vérifier le code d'autorisation dans l'URL
|
||||
const checkForAuthCode = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
console.error('Erreur d\'authentification Microsoft:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
console.log('Code d\'autorisation reçu, échange contre un token...');
|
||||
const success = await exchangeCodeForToken(code);
|
||||
|
||||
// Nettoyer l'URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return success;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Générer un code_verifier et un code_challenge pour PKCE
|
||||
async function generatePKCE() {
|
||||
const array = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(array);
|
||||
const codeVerifier = btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const digest = await window.crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier));
|
||||
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
// Échanger le code contre un token (via le backend)
|
||||
// Dans exchangeCodeForToken, remplacez l'appel au backend par un appel direct à Microsoft
|
||||
const exchangeCodeForToken = async (code: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3002/api/login', {
|
||||
setLoading(true);
|
||||
const codeVerifier = localStorage.getItem("pkce_verifier");
|
||||
|
||||
if (!codeVerifier) {
|
||||
throw new Error('Code verifier manquant');
|
||||
}
|
||||
|
||||
// Échanger directement avec Microsoft depuis le frontend
|
||||
const params = new URLSearchParams();
|
||||
params.append('client_id', CLIENT_ID);
|
||||
params.append('code', code);
|
||||
params.append('redirect_uri', REDIRECT_URI);
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('scope', 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/Group.Read.All');
|
||||
params.append('code_verifier', codeVerifier);
|
||||
|
||||
const response = await fetch(`https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_description || 'Échec de l\'échange du code');
|
||||
}
|
||||
|
||||
const tokenData = await response.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
// Récupérer les infos utilisateur directement
|
||||
const userResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
const userInfo = await userResponse.json();
|
||||
|
||||
// Maintenant vérifier l'autorisation via votre backend
|
||||
const authResponse = await fetch('http://localhost:3002/api/check-user-groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userPrincipalName: userInfo.mail || userInfo.userPrincipalName
|
||||
})
|
||||
});
|
||||
|
||||
const authData = await authResponse.json();
|
||||
|
||||
if (!authData.authorized) {
|
||||
throw new Error(authData.message || 'Utilisateur non autorisé');
|
||||
}
|
||||
|
||||
// Stocker les informations
|
||||
localStorage.setItem('o365_token', accessToken);
|
||||
localStorage.setItem('user_data', JSON.stringify(authData.user));
|
||||
localStorage.setItem('user_email', authData.user.email);
|
||||
|
||||
setUser(authData.user);
|
||||
setIsAuthorized(true);
|
||||
localStorage.removeItem("pkce_verifier");
|
||||
|
||||
console.log('Authentification réussie:', authData.user);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'échange du code:', error);
|
||||
localStorage.removeItem("pkce_verifier");
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction de login local (pour compatibilité)
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/login-hybrid', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
mot_de_passe: password
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Identifiants incorrects');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
setUser(data.user);
|
||||
setIsAuthorized(true);
|
||||
|
||||
return true;
|
||||
if (data.success && data.user) {
|
||||
localStorage.setItem('user_data', JSON.stringify(data.user));
|
||||
localStorage.setItem('user_email', data.user.email);
|
||||
setUser(data.user);
|
||||
setIsAuthorized(true);
|
||||
return true;
|
||||
} else {
|
||||
console.error('Erreur login local:', data.message);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur de connexion:', error);
|
||||
throw error;
|
||||
console.error('Erreur lors du login local:', error);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Connexion Office 365
|
||||
const loginWithO365 = async () => {
|
||||
const loginWithO365 = async (): Promise<boolean> => {
|
||||
try {
|
||||
// Simuler l'authentification Office 365
|
||||
// Remplacez cette partie par votre vraie logique O365
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'user@office365.com',
|
||||
name: 'Utilisateur O365',
|
||||
role: 'rh'
|
||||
};
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
localStorage.setItem("pkce_verifier", codeVerifier);
|
||||
|
||||
const mockToken = 'mock-o365-token';
|
||||
const authUrl = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize?` +
|
||||
`client_id=${CLIENT_ID}&` +
|
||||
`response_type=code&` +
|
||||
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
|
||||
`response_mode=query&` +
|
||||
`scope=${encodeURIComponent('User.Read User.Read.All Group.Read.All GroupMember.Read.All')}&` +
|
||||
`code_challenge=${codeChallenge}&` +
|
||||
`code_challenge_method=S256&` +
|
||||
`state=random_state_value`;
|
||||
|
||||
localStorage.setItem('o365_token', mockToken);
|
||||
localStorage.setItem('user', JSON.stringify(mockUser));
|
||||
console.log('🔑 PKCE généré et stocké');
|
||||
console.log('🌐 Redirection vers Microsoft:', authUrl);
|
||||
|
||||
setUser(mockUser);
|
||||
setIsAuthorized(true);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erreur connexion O365:', error);
|
||||
throw error;
|
||||
window.location.href = authUrl;
|
||||
return true; // La vraie validation se fera au retour
|
||||
} catch (err) {
|
||||
console.error("Erreur génération PKCE:", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Déconnexion
|
||||
// Déconnexion
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('o365_token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('user_data');
|
||||
localStorage.removeItem('user_email');
|
||||
localStorage.removeItem('user_role');
|
||||
localStorage.removeItem('local_user_id');
|
||||
localStorage.removeItem('pkce_verifier');
|
||||
|
||||
setUser(null);
|
||||
setIsAuthorized(false);
|
||||
@@ -108,6 +263,7 @@ export const AuthProvider = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">Authentification en cours...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user