GTFRH_V1
This commit is contained in:
27
GTFRRH/project/src/.dockerignore
Normal file
27
GTFRRH/project/src/.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
||||
# Dépendances
|
||||
node_modules
|
||||
|
||||
# Cache et build
|
||||
.vite
|
||||
dist
|
||||
node_modules/.vite
|
||||
*.log
|
||||
|
||||
# Environnement
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Autres
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
25
GTFRRH/project/src/AuthConfig.tsx
Normal file
25
GTFRRH/project/src/AuthConfig.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
|
||||
|
||||
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: "https://mygtf-rh.ensup-adm.net"
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "sessionStorage",
|
||||
storeAuthStateInCookie: false,
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: [
|
||||
"User.Read",
|
||||
"User.Read.All", // Pour lire les profils des autres utilisateurs
|
||||
"Group.Read.All", // Pour lire les groupes
|
||||
"GroupMember.Read.All", // Pour lire les membres des groupes
|
||||
"Mail.Send" //Envoyer les emails.
|
||||
]
|
||||
};
|
||||
257
GTFRRH/project/src/components/CloturePeriode.tsx
Normal file
257
GTFRRH/project/src/components/CloturePeriode.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Lock, Unlock, Calendar, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Cloture {
|
||||
id: number;
|
||||
mois_annee: string;
|
||||
date_debut: string;
|
||||
date_fin: string;
|
||||
campus: string | null;
|
||||
cloture_par: string;
|
||||
date_cloture: string;
|
||||
commentaire: string | null;
|
||||
}
|
||||
|
||||
const ClotureManager: React.FC = () => {
|
||||
const [clotures, setClotures] = useState<Cloture[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [selectedCampus, setSelectedCampus] = useState<string>('all');
|
||||
const [commentaire, setCommentaire] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille'];
|
||||
|
||||
const loadClotures = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/clotures');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setClotures(data.clotures);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement clôtures:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadClotures();
|
||||
}, []);
|
||||
|
||||
const handleCloturer = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
|
||||
const dateDebutStr = `${year}-${month}-01`;
|
||||
const lastDay = new Date(parseInt(year), parseInt(month), 0).getDate();
|
||||
const dateFinStr = `${year}-${month}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const response = await fetch('/api/cloturer-periode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mois_annee: selectedMonth,
|
||||
date_debut: dateDebutStr,
|
||||
date_fin: dateFinStr,
|
||||
campus: selectedCampus === 'all' ? null : selectedCampus,
|
||||
commentaire
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('Période clôturée avec succès');
|
||||
setCommentaire('');
|
||||
loadClotures();
|
||||
} else {
|
||||
setError(data.error || 'Erreur lors de la clôture');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRouvrir = async (id: number) => {
|
||||
if (!confirm('Voulez-vous vraiment rouvrir cette période ?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rouvrir-periode/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('Période réouverte avec succès');
|
||||
loadClotures();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateSQL = (dateString: string) => {
|
||||
if (!dateString) return 'Date inconnue';
|
||||
|
||||
try {
|
||||
let dateOnly = dateString;
|
||||
|
||||
if (dateString.includes('T')) {
|
||||
dateOnly = dateString.split('T')[0];
|
||||
}
|
||||
|
||||
const [year, month, day] = dateOnly.split('-').map(num => parseInt(num, 10));
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Date invalide';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur formatage date:', dateString, error);
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Lock className="text-red-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
Gestion des clôtures de saisie
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="text-red-600" size={20} />
|
||||
<span className="text-red-800">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-gray-800 mb-4">
|
||||
Clôturer une nouvelle période
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mois à clôturer
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commentaire}
|
||||
onChange={(e) => setCommentaire(e.target.value)}
|
||||
placeholder="Ex: Clôture mensuelle"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCloturer}
|
||||
disabled={loading}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Lock size={20} />
|
||||
{loading ? 'Clôture en cours...' : 'Clôturer cette période'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-4">
|
||||
Périodes clôturées ({clotures.length})
|
||||
</h3>
|
||||
|
||||
{clotures.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
Aucune période clôturée
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{clotures.map(cloture => (
|
||||
<div
|
||||
key={cloture.id}
|
||||
className="flex items-center justify-between p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} className="text-red-600" />
|
||||
<span className="font-medium text-gray-800">
|
||||
{formatDateSQL(cloture.date_debut)}
|
||||
{' → '}
|
||||
{formatDateSQL(cloture.date_fin)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
({cloture.mois_annee})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Campus: {cloture.campus || 'Tous'} •
|
||||
Clôturé le {formatDateSQL(cloture.date_cloture)}
|
||||
{cloture.commentaire && ` • ${cloture.commentaire}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRouvrir(cloture.id)}
|
||||
className="ml-4 px-4 py-2 bg-white hover:bg-gray-50 text-red-600 border border-red-300 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Unlock size={16} />
|
||||
Rouvrir
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClotureManager;
|
||||
@@ -1,208 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Users } from 'lucide-react';
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
const Login = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { loginWithO365, isAuthorized } = useAuth();
|
||||
|
||||
// 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, navigate]);
|
||||
const { loginWithO365 } = useAuth();
|
||||
|
||||
const handleO365Login = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// 1. Connexion Office 365 via votre contexte existant
|
||||
const success = await loginWithO365();
|
||||
|
||||
if (!success) {
|
||||
setError("Erreur lors de l'initialisation de la connexion Office 365");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
setError("Erreur lors de la connexion Office 365");
|
||||
} else {
|
||||
navigate("/dashboard");
|
||||
}
|
||||
|
||||
// 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");
|
||||
} finally {
|
||||
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");
|
||||
|
||||
if (!token || !userEmail) {
|
||||
setError("Token ou email utilisateur manquant");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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: any) {
|
||||
console.error('Erreur lors de la finalisation de l\'authentification:', err);
|
||||
|
||||
// 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 finalisation de la connexion");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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">
|
||||
<div className="min-h-screen flex">
|
||||
{/* Colonne gauche avec image + overlay */}
|
||||
<div className="hidden md:flex w-1/2 relative">
|
||||
<img
|
||||
src="/backend/images/Ensup.png"
|
||||
alt="ENSUP"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#6d4a91]/80 to-[#7e5aa2]/40 flex flex-col justify-center items-center text-white p-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Bienvenue chez ENSUP</h1>
|
||||
<p className="text-lg text-gray-200">
|
||||
Espace RH - Gestion et Connexion Sécurisée
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* En-tête */}
|
||||
{/* Colonne droite avec le login */}
|
||||
<div className="flex w-full md:w-1/2 items-center justify-center p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md bg-white shadow-xl rounded-2xl p-8">
|
||||
<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
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Connexion à l’espace RH
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Utilisez votre compte Office 365
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton de connexion Office 365 */}
|
||||
{/* Bouton violet ENSUP */}
|
||||
<button
|
||||
onClick={handleO365Login}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2 shadow-md disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Connexion en cours...</span>
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<span>Se connecter avec Office 365</span>
|
||||
</>
|
||||
<>Se connecter avec Office 365</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<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é' :
|
||||
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é') && (
|
||||
<p className="text-red-700 text-xs mt-2">
|
||||
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 className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-800 font-medium text-sm">
|
||||
Erreur de connexion
|
||||
</p>
|
||||
<p className="text-red-700 text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
© {new Date().getFullYear()} ENSUP / ENSITECH. Tous droits réservés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, Lock, Edit2, Trash2, X, Save } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import ClotureManager from '../components/CloturePeriode';
|
||||
|
||||
interface TimeEntry {
|
||||
id: string;
|
||||
formateur: string;
|
||||
campus: string;
|
||||
date: string;
|
||||
type: 'preparation' | 'correction';
|
||||
type: 'preparation';
|
||||
hours: number;
|
||||
description: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
heure_debut?: string;
|
||||
heure_fin?: string;
|
||||
formateur_numero?: number;
|
||||
formateur_email?: string; // NOUVEAU CHAMP
|
||||
formateur_email?: string;
|
||||
type_demande_id?: number;
|
||||
}
|
||||
|
||||
interface FormateurAvecDeclarations {
|
||||
@@ -42,7 +44,8 @@ const RHDashboard: React.FC = () => {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${year}-${month}`;
|
||||
});
|
||||
const { logout } = useAuth();
|
||||
const [showClotureManager, setShowClotureManager] = useState(false);
|
||||
const { logout, user } = useAuth();
|
||||
const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
|
||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||
const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]);
|
||||
@@ -51,54 +54,34 @@ const RHDashboard: React.FC = () => {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
|
||||
// Fonction pour normaliser/mapper les noms de campus
|
||||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
date: '',
|
||||
hours: 0,
|
||||
heure_debut: '',
|
||||
heure_fin: '',
|
||||
description: '',
|
||||
type: 'preparation' as 'preparation'
|
||||
});
|
||||
|
||||
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',
|
||||
'MRS': 'Marseille', 'mrs': 'Marseille',
|
||||
'NTE': 'Nantes', 'nte': 'Nantes',
|
||||
'CGY': 'Cergy', 'cgy': 'Cergy',
|
||||
'SQY': 'SQY', 'sqy': 'SQY', 'ensqy': 'SQY',
|
||||
'SQY/CGY': 'SQY/CGY', 'sqy/cgy': 'SQY/CGY',
|
||||
'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'
|
||||
'MARSEILLE': 'Marseille', 'NANTES': 'Nantes', 'CERGY': 'Cergy',
|
||||
'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++) {
|
||||
@@ -109,10 +92,9 @@ const RHDashboard: React.FC = () => {
|
||||
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');
|
||||
const response = await fetch('/api/diagnostic');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSystemStatus(data.systemStatus);
|
||||
@@ -123,11 +105,9 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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 =>
|
||||
@@ -139,13 +119,12 @@ const RHDashboard: React.FC = () => {
|
||||
return {
|
||||
nom: formateurTrouve.nom,
|
||||
prenom: formateurTrouve.prenom,
|
||||
campus: formateurTrouve.campus, // Garder le campus original de la vue
|
||||
campus: formateurTrouve.campus,
|
||||
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 {
|
||||
@@ -156,11 +135,8 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -180,7 +156,6 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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' }
|
||||
@@ -197,7 +172,6 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
}
|
||||
|
||||
// FALLBACK FINAL
|
||||
const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu';
|
||||
console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`);
|
||||
return {
|
||||
@@ -208,16 +182,13 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// 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');
|
||||
const response = await fetch('/api/formateurs-vue');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
@@ -225,7 +196,6 @@ const RHDashboard: React.FC = () => {
|
||||
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,
|
||||
@@ -242,16 +212,15 @@ const RHDashboard: React.FC = () => {
|
||||
} 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');
|
||||
const fallbackResponse = await fetch('/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
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
@@ -263,14 +232,13 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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');
|
||||
const response = await fetch('/api/get_declarations');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`);
|
||||
@@ -280,30 +248,29 @@ const RHDashboard: React.FC = () => {
|
||||
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
|
||||
campus: normalizeCampus(formateurInfo.campus),
|
||||
date: d.date.split('T')[0],
|
||||
type: d.activityType,
|
||||
type: 'preparation',
|
||||
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
|
||||
formateur_email: d.formateur_email || d.formateur_email_fk,
|
||||
type_demande_id: d.type_demande_id
|
||||
};
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -315,14 +282,12 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
@@ -335,12 +300,10 @@ const RHDashboard: React.FC = () => {
|
||||
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 = {
|
||||
@@ -359,21 +322,17 @@ const RHDashboard: React.FC = () => {
|
||||
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;
|
||||
@@ -386,19 +345,15 @@ const RHDashboard: React.FC = () => {
|
||||
|
||||
setFormateursAvecDeclarations(formateursAvecCompte);
|
||||
}
|
||||
}, [timeEntries.length]); // Déclenché quand le nombre de déclarations change
|
||||
}, [timeEntries.length]);
|
||||
|
||||
// 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');
|
||||
@@ -406,7 +361,6 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
}, [selectedCampus, formateursAvecDeclarations]);
|
||||
|
||||
// Fonction de déconnexion
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('o365_token');
|
||||
@@ -415,14 +369,83 @@ const RHDashboard: React.FC = () => {
|
||||
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 handleEdit = (entry: TimeEntry) => {
|
||||
setEditingEntry(entry);
|
||||
setEditForm({
|
||||
date: entry.date,
|
||||
hours: entry.hours,
|
||||
heure_debut: entry.heure_debut || '',
|
||||
heure_fin: entry.heure_fin || '',
|
||||
description: entry.description,
|
||||
type: entry.type
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingEntry) return;
|
||||
|
||||
try {
|
||||
const type_demande_id = editingEntry.type_demande_id || 1;
|
||||
|
||||
console.log('Mise à jour avec type_demande_id:', type_demande_id);
|
||||
|
||||
const response = await fetch(`/api/declarations/${editingEntry.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date: editForm.date,
|
||||
duree: editForm.hours,
|
||||
heure_debut: editForm.heure_debut || null,
|
||||
heure_fin: editForm.heure_fin || null,
|
||||
description: editForm.description,
|
||||
type_demande_id: type_demande_id
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
setEditingEntry(null);
|
||||
await loadDeclarations();
|
||||
} else {
|
||||
alert('Erreur: ' + result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erreur lors de la modification:', error);
|
||||
alert('Erreur lors de la modification: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: TimeEntry) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer cette déclaration de ${entry.formateur} du ${new Date(entry.date).toLocaleDateString('fr-FR')} ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/declarations/${entry.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
await loadDeclarations();
|
||||
} else {
|
||||
alert('Erreur: ' + result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('Erreur lors de la suppression: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEntries = timeEntries.filter(entry => {
|
||||
const entryNormalizedCampus = normalizeCampus(entry.campus);
|
||||
const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus;
|
||||
@@ -431,75 +454,60 @@ const RHDashboard: React.FC = () => {
|
||||
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
|
||||
campus: normalizeCampus(formateur.campus)
|
||||
});
|
||||
} 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 };
|
||||
return { totalHours };
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Heure Début', 'Heure Fin', 'Description'];
|
||||
const csvHeaders = ['Date', 'Formateur', 'Campus', '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) => {
|
||||
const cleanCsvValue = (value: any) => {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '""') // Doubler les guillemets
|
||||
.replace(/;/g, ','); // Remplacer ; par , dans les données
|
||||
.replace(/"/g, '""')
|
||||
.replace(/;/g, ',');
|
||||
};
|
||||
|
||||
// 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
|
||||
.map(row => row.map(cell => cleanCsvValue(cell)).join(';'))
|
||||
.join('\r\n');
|
||||
|
||||
const blob = new Blob([csvContent], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
@@ -563,17 +571,23 @@ const RHDashboard: React.FC = () => {
|
||||
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>
|
||||
{user && (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-gray-700">
|
||||
Connecté en tant que <span className="font-semibold">{user.nom} {user.prenom}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -596,7 +610,6 @@ const RHDashboard: React.FC = () => {
|
||||
</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">
|
||||
@@ -607,7 +620,6 @@ const RHDashboard: React.FC = () => {
|
||||
</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>
|
||||
@@ -619,13 +631,23 @@ const RHDashboard: React.FC = () => {
|
||||
|
||||
{!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="mb-6">
|
||||
<button
|
||||
onClick={() => setShowClotureManager(!showClotureManager)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors shadow-md"
|
||||
>
|
||||
<Lock size={20} />
|
||||
{showClotureManager ? 'Masquer la gestion des clôtures' : 'Gérer les clôtures de saisie'}
|
||||
</button>
|
||||
</div>
|
||||
{showClotureManager && <ClotureManager />}
|
||||
|
||||
<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">
|
||||
@@ -688,7 +710,6 @@ const RHDashboard: React.FC = () => {
|
||||
</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);
|
||||
@@ -703,21 +724,12 @@ const RHDashboard: React.FC = () => {
|
||||
<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>
|
||||
@@ -737,7 +749,6 @@ const RHDashboard: React.FC = () => {
|
||||
</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">
|
||||
@@ -750,30 +761,14 @@ const RHDashboard: React.FC = () => {
|
||||
<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>
|
||||
<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">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">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
@@ -798,21 +793,6 @@ const RHDashboard: React.FC = () => {
|
||||
<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>
|
||||
@@ -831,6 +811,24 @@ const RHDashboard: React.FC = () => {
|
||||
<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 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(entry)}
|
||||
className="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -838,6 +836,101 @@ const RHDashboard: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditModal && editingEntry && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between sticky top-0 bg-white">
|
||||
<h3 className="text-xl font-bold text-gray-800">Modifier la déclaration</h3>
|
||||
<button onClick={() => setShowEditModal(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Formateur</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingEntry.formateur}
|
||||
disabled
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.date}
|
||||
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Nombre d'heures</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={editForm.hours}
|
||||
onChange={(e) => setEditForm({ ...editForm, hours: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Heure début</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editForm.heure_debut}
|
||||
onChange={(e) => setEditForm({ ...editForm, heure_debut: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Heure fin</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editForm.heure_fin}
|
||||
onChange={(e) => setEditForm({ ...editForm, heure_fin: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Description de l'activité..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
// Configuration Microsoft OAuth
|
||||
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
|
||||
const REDIRECT_URI = 'http://localhost:5174';
|
||||
const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net';
|
||||
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
|
||||
|
||||
// Vérifier l'état d'authentification au chargement
|
||||
@@ -136,7 +136,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const userInfo = await userResponse.json();
|
||||
|
||||
// Maintenant vérifier l'autorisation via votre backend
|
||||
const authResponse = await fetch('http://localhost:3002/api/check-user-groups', {
|
||||
const authResponse = await fetch('/api/check-user-groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -179,7 +179,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/login-hybrid', {
|
||||
const response = await fetch('/api/login-hybrid', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
Reference in New Issue
Block a user