add project files

This commit is contained in:
2026-06-26 14:50:55 +02:00
parent f2d0cd6a14
commit 811dc6b848
55770 changed files with 7418342 additions and 2 deletions
+316
View File
@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import Calendar from './components/Calendar';
import TimeEntryModal from './components/TimeEntryModal';
import UserProfile from './components/UserProfile';
import { TimeEntry } from './types/TimeEntry';
import { Clock, User, Calendar as CalendarIcon, Menu, X, LogOut } from 'lucide-react';
import { useAuth } from './context/AuthContext';
function App() {
const { logout, user } = useAuth();
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [showProfile, setShowProfile] = useState(false);
const [loading, setLoading] = useState(true);
// Fonction pour charger les déclarations filtrées par utilisateur
const loadDeclarations = async () => {
try {
console.log('Chargement des déclarations...');
// Vérifier si l'utilisateur est connecté
if (!user?.email) {
console.log('Aucun utilisateur connecté');
setTimeEntries([]);
setLoading(false);
return;
}
// CORRECTION : Utiliser l'email au lieu de l'ID
const response = await fetch(`/api/get_declarations_by_email?email=${encodeURIComponent(user.email)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Déclarations reçues pour l\'email', user.email, ':', data);
// Convertir les données du serveur au format TimeEntry
const entries: TimeEntry[] = data.map((d: any) => ({
date: d.date.split('T')[0], // Convertir la date SQL en format YYYY-MM-DD
activityType: d.activityType,
hours: d.duree,
description: d.description || '',
createdAt: new Date(d.date)
}));
setTimeEntries(entries);
console.log('Déclarations converties pour l\'utilisateur:', entries);
} catch (error) {
console.error('Erreur lors du chargement des déclarations:', error);
setTimeEntries([]);
} finally {
setLoading(false);
}
};
// Recharger quand l'utilisateur change
useEffect(() => {
if (user?.email) {
loadDeclarations();
} else {
setTimeEntries([]);
setLoading(false);
}
}, [user?.email]);
const handleDateClick = (date: string) => setSelectedDate(date);
const handleCloseModal = () => setSelectedDate(null);
const handleSaveEntry = async (entry: TimeEntry) => {
console.log('Sauvegarde réussie, rechargement des données...');
// Recharger les données depuis la base après sauvegarde
await loadDeclarations();
setSelectedDate(null);
};
const getEntryForDate = (date: string): TimeEntry | undefined => {
return timeEntries.find(entry => entry.date === date);
};
const getTotalCurrentMonthHours = () => {
const currentDate = new Date();
const currentMonth = currentDate.getMonth();
const currentYear = currentDate.getFullYear();
return timeEntries
.filter(entry => {
const entryDate = new Date(entry.date);
return entryDate.getMonth() === currentMonth && entryDate.getFullYear() === currentYear;
})
.reduce((total, entry) => total + entry.hours, 0);
};
const getRecentEntries = () => {
return timeEntries
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3);
};
// Vérification si utilisateur connecté
if (!user) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
<div className="text-gray-600">Chargement de la session...</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50">
{/* Header */}
<div className="bg-white/80 backdrop-blur-sm border-b border-gray-100 sticky top-0 z-10">
<div className="container mx-auto px-4 py-4 sm:py-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<CalendarIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">GTF</h1>
<p className="text-sm text-gray-600">
Formateurs - Suivi mensuel {user.nom} {user.prenom}
</p>
</div>
</div>
{/* Quick Stats & Profile Button */}
<div className="flex items-center gap-4">
<button
onClick={() => setShowProfile(!showProfile)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors sm:hidden"
>
{showProfile ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<div className="text-center">
<div className="text-lg sm:text-xl font-bold text-blue-600">
{getTotalCurrentMonthHours()}h
</div>
<div className="text-xs sm:text-sm text-gray-600">Ce mois</div>
</div>
<div className="text-center">
<div className="text-lg sm:text-xl font-bold text-green-600">
{timeEntries.length}
</div>
<div className="text-xs sm:text-sm text-gray-600">Déclarations</div>
</div>
<button
onClick={logout}
className="flex items-center gap-2 px-3 py-2 bg-red-50 hover:bg-red-100 text-red-600 font-medium rounded-lg transition-colors"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Déconnexion</span>
</button>
</div>
</div>
</div>
</div>
<div className="container mx-auto px-4 py-6 sm:py-8">
{/* Mobile Profile */}
{showProfile && (
<div className="mb-6 sm:hidden">
<UserProfile
user={user}
totalHours={getTotalCurrentMonthHours()}
totalEntries={timeEntries.length}
/>
</div>
)}
{/* Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 sm:gap-8">
{/* Calendar - Main Section */}
<div className="xl:col-span-2">
{loading ? (
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600">Chargement des déclarations...</div>
</div>
) : (
<Calendar
onDateClick={handleDateClick}
getEntryForDate={getEntryForDate}
currentUserId={user.id}
/>
)}
</div>
{/* Sidebar */}
<div className="space-y-6 xl:col-span-1">
{/* Desktop Profile */}
<div className="hidden sm:block">
<UserProfile
user={user}
totalHours={getTotalCurrentMonthHours()}
totalEntries={timeEntries.length}
/>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-2xl shadow-lg p-4 sm:p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-600" />
Actions rapides
</h3>
<div className="space-y-3">
<button
onClick={() => handleDateClick(new Date().toISOString().split('T')[0])}
className="w-full p-3 bg-blue-50 hover:bg-blue-100 text-blue-700 font-medium rounded-lg transition-colors text-left"
disabled={loading}
>
{loading ? 'Chargement...' : 'Déclarer aujourd\'hui'}
</button>
<div className="text-sm text-gray-600">
<p className="mb-2">Limites:</p>
<ul className="space-y-1 text-xs">
<li> Lundi à vendredi uniquement</li>
<li> Description optionnelle</li>
</ul>
</div>
</div>
</div>
{/* Recent Entries */}
{!loading && timeEntries.length > 0 && (
<div className="bg-white rounded-2xl shadow-lg p-4 sm:p-6 border border-gray-100">
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<Clock className="h-5 w-5 text-green-600" />
Récentes déclarations
</h3>
<div className="space-y-3">
{getRecentEntries().map((entry, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg">
<div className="flex justify-between items-start mb-1">
<span className="text-sm font-medium text-gray-800">
{new Date(entry.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short'
})}
</span>
<span className={`
text-xs px-2 py-1 rounded-full
${entry.activityType === 'preparation'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}
`}>
{entry.hours}h
</span>
</div>
{entry.description && (
<p className="text-xs text-gray-600 truncate">
{entry.description}
</p>
)}
</div>
))}
</div>
</div>
)}
{/* No Data Message */}
{!loading && timeEntries.length === 0 && (
<div className="bg-white rounded-2xl shadow-lg p-4 sm:p-6 border border-gray-100 text-center">
<h3 className="text-lg font-semibold text-gray-800 mb-2">Aucune déclaration</h3>
<p className="text-sm text-gray-600 mb-4">
Commencez par déclarer vos premières heures pour {user.prenom}
</p>
<button
onClick={() => handleDateClick(new Date().toISOString().split('T')[0])}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Déclarer maintenant
</button>
</div>
)}
{/* Help Section */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-4 sm:p-6 border border-blue-100">
<h3 className="text-lg font-semibold text-gray-800 mb-3">
Aide
</h3>
<div className="space-y-2 text-sm text-gray-600">
<p> Cliquez sur une date pour déclarer vos heures</p>
<p> Choisissez entre préparation ou correction</p>
<p> Description optionnelle mais recommandée</p>
</div>
</div>
</div>
</div>
</div>
{/* Modal */}
{selectedDate && !loading && (
<TimeEntryModal
date={selectedDate}
existingEntry={getEntryForDate(selectedDate)}
onClose={handleCloseModal}
onSave={handleSaveEntry}
/>
)}
</div>
);
}
export default App;
+25
View 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.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.
]
};
@@ -0,0 +1,323 @@
import React, { useState, useEffect } from 'react';
import { X, Clock, FileText, Save, AlertCircle } from 'lucide-react';
import { TimeEntry } from '../types/TimeEntry';
import { useAuth } from '../context/AuthContext';
interface TimeEntryModalProps {
date: string;
existingEntry?: TimeEntry;
onClose: () => void;
onSave: (entry: TimeEntry) => void;
}
const TimeEntryModal: React.FC<TimeEntryModalProps> = ({
date,
existingEntry,
onClose,
onSave
}) => {
const [activityType, setActivityType] = useState<'preparation' | 'correction'>('preparation');
const [hours, setHours] = useState<number>(0);
const [description, setDescription] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [errors, setErrors] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const { user, isAuthorized } = useAuth();
useEffect(() => {
if (existingEntry) {
setActivityType(existingEntry.activityType);
setHours(existingEntry.hours);
setDescription(existingEntry.description);
setStartTime(existingEntry.startTime ?? '');
setEndTime(existingEntry.endTime ?? '');
}
}, [existingEntry]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Calcule la différence en heures entre start et end (format "HH:mm")
const calculateHours = (start: string, end: string): number => {
if (!start || !end) return 0;
const dateRef = '2000-01-01T';
let startDate = new Date(dateRef + start);
let endDate = new Date(dateRef + end);
// Si fin avant début, on considère passage au lendemain
if (endDate <= startDate) {
endDate.setDate(endDate.getDate() + 1);
}
const diffMs = endDate.getTime() - startDate.getTime();
return Math.round((diffMs / (1000 * 60 * 60)) * 10) / 10; // arrondi au 0.1 h près
};
// Gère changement heure début
const handleStartTimeChange = (value: string) => {
setStartTime(value);
const newHours = calculateHours(value, endTime);
setHours(newHours);
};
// Gère changement heure fin
const handleEndTimeChange = (value: string) => {
setEndTime(value);
const newHours = calculateHours(startTime, value);
setHours(newHours);
};
const validateForm = (): boolean => {
const newErrors: string[] = [];
if (hours < 0.5 || hours > 8) {
newErrors.push('Le nombre d\'heures doit être entre 0.5 et 8');
}
if (description.trim() && description.trim().length < 10) {
newErrors.push('Si fournie, la description doit contenir au moins 10 caractères');
}
if (!startTime) {
newErrors.push("L'heure de début est obligatoire");
}
if (!endTime) {
newErrors.push("L'heure de fin est obligatoire");
}
if (startTime && endTime && startTime >= endTime) {
// Permet à endTime d'être le lendemain, donc ce contrôle est à adapter,
// mais thème validation simple ici:
const diff = calculateHours(startTime, endTime);
if (diff <= 0) {
newErrors.push("L'heure de fin doit être après l'heure de début");
}
}
setErrors(newErrors);
return newErrors.length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setLoading(true);
setErrors([]);
const entry: TimeEntry = {
date,
activityType,
hours,
description: description.trim(),
startTime,
endTime,
createdAt: new Date(),
};
try {
const response = await fetch("/api/save_declaration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
date,
activityType,
hours,
description: description.trim(),
startTime,
endTime,
user,
}),
});
if (!response.ok) {
const errorMsg = await response.text();
setErrors([`Erreur serveur: ${errorMsg}`]);
setLoading(false);
return;
}
onSave(entry);
onClose();
} catch (err: any) {
setErrors(['Erreur réseau lors de lenvoi de la déclaration.']);
} finally {
setLoading(false);
}
};
const getActivityTypeLabel = (type: 'preparation' | 'correction') => {
return type === 'preparation' ? 'Préparation et Recherche' : 'Correction d\'Examens';
};
const getActivityTypeColor = (type: 'preparation' | 'correction') => {
return type === 'preparation' ? 'blue' : 'green';
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-auto transform transition-all max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-100 sticky top-0 bg-white rounded-t-2xl">
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-xl font-bold text-gray-800">
Déclaration d'heures
</h2>
<p className="text-xs sm:text-sm text-gray-600 capitalize truncate">
{formatDate(date)}
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
disabled={loading}
>
<X className="h-5 w-5 text-gray-500" />
</button>
</div>
{/* Error Messages */}
{errors.length > 0 && (
<div className="mx-4 sm:mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div className="ml-2">
<h3 className="text-sm font-medium text-red-800">Erreurs de validation</h3>
<ul className="mt-1 text-sm text-red-700 list-disc list-inside">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 sm:p-6 space-y-6">
{/* Type d'activité */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Type d'activité <span className="text-red-500">*</span>
</label>
<div className="space-y-3">
{(['preparation', 'correction'] as const).map((type) => {
const color = getActivityTypeColor(type);
const isSelected = activityType === type;
return (
<label
key={type}
className={`
flex items-center p-3 sm:p-4 border-2 rounded-xl cursor-pointer transition-all
${isSelected
? `border-${color}-500 bg-${color}-50 shadow-sm`
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
<input
type="radio"
name="activityType"
value={type}
checked={isSelected}
onChange={(e) => setActivityType(e.target.value as 'preparation' | 'correction')}
className={`w-4 h-4 text-${color}-600 flex-shrink-0`}
/>
<div className="ml-3 flex items-center min-w-0">
{type === 'preparation' ? (
<FileText className={`h-5 w-5 text-${color}-600 mr-2 flex-shrink-0`} />
) : (
<Clock className={`h-5 w-5 text-${color}-600 mr-2 flex-shrink-0`} />
)}
<span className="font-medium text-gray-800 text-sm sm:text-base">
{getActivityTypeLabel(type)}
</span>
</div>
</label>
);
})}
</div>
</div>
{/* Heure de début */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Heure de début <span className="text-red-500">*</span>
</label>
<input
type="time"
value={startTime}
onChange={e => handleStartTimeChange(e.target.value)}
required
disabled={loading}
className="w-full p-3 border-2 border-gray-300 rounded-xl focus:border-blue-500"
/>
</div>
{/* Heure de fin */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Heure de fin <span className="text-red-500">*</span>
</label>
<input
type="time"
value={endTime}
onChange={e => handleEndTimeChange(e.target.value)}
required
disabled={loading}
className="w-full p-3 border-2 border-gray-300 rounded-xl focus:border-blue-500"
/>
</div>
{/* Nombre d'heures calculé */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre d'heures
</label>
<input
type="number"
value={hours}
readOnly
className="w-full p-3 border-2 border-gray-300 rounded-xl bg-gray-100 cursor-not-allowed"
/>
<p className="text-xs text-gray-500 mt-2">
Calculé automatiquement à partir de l'heure de début et de fin
</p>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description de l'activité <span className="text-gray-400">(optionnel)</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full p-3 border-2 border-gray-300 rounded-xl focus:border-blue-500 focus:ring-0 transition-colors resize-none text-base"
placeholder={`Décrivez votre activité de ${getActivityTypeLabel(activityType).toLowerCase()}...`}
rows={4}
disabled={loading}
/>
<div className="flex justify-between items-center mt-2">
<p className="text-xs text-gray-500">
{description.trim() ? 'Minimum 10 caractères si fournie' : 'Description optionnelle'}
</p>
<p className="text-xs text-gray-500">
{description.length}/500
</p>
</div>
</div>
{/* Buttons */}
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 border-2 border-gray-300 text-gray-700 font-medium rounded-xl hover:bg-gray-50 transition-colors text-base"
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 text-base"
disabled={loading}
>
<Save className="h-5 w-5" />
{existingEntry ? 'Modifier' : 'Enregistrer'}
</button>
</div>
</form>
</div>
</div>
);
};
export default TimeEntryModal;
@@ -0,0 +1,84 @@
import React from 'react';
import { User, Mail, Calendar, Clock, Award,AlertTriangle } from 'lucide-react';
interface UserProfileProps {
user: {
id?: number;
nom: string;
prenom: string;
email: string;
department?: string;
role?: string;
joinDate?: string;
};
totalHours: number;
totalEntries: number;
}
const UserProfile: React.FC<UserProfileProps> = ({ user, totalHours, totalEntries }) => {
// Valeurs par défaut si les données manquent
const role ='Formateur';
const joinDate = user.joinDate || new Date().getFullYear().toString();
return (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="bg-[#198ab4] p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-16 h-16 sm:w-20 sm:h-20 bg-white/20 rounded-full flex items-center justify-center">
<User className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div className="text-center sm:text-left">
<h2 className="text-xl sm:text-2xl font-bold text-white">
{user.prenom} {user.nom}
</h2>
<p className="text-blue-100 text-sm sm:text-base">{role}</p>
</div>
</div>
</div>
{/* Informations du profil */}
<div className="p-4 sm:p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<Mail className="h-5 w-5 text-gray-600 flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-gray-500">Email</p>
<p className="text-sm font-medium text-gray-800 truncate">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<Calendar className="h-5 w-5 text-gray-600 flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-gray-500">Depuis</p>
<p className="text-sm font-medium text-gray-800">{joinDate}</p>
</div>
</div>
</div>
{/* Statistiques */}
<div className="border-t border-gray-100 pt-4">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Statistiques du mois</h3>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl sm:text-3xl font-bold text-cyan-600">{totalHours}</div>
<div className="text-xs sm:text-sm text-cyan-600">Heures déclarées</div>
</div>
<div className="text-center p-4 bg-[#7e5aa2cc] rounded-lg">
<div className="text-2xl sm:text-3xl font-bold text-[#fff]">{totalEntries}</div>
<p className="text-xs sm:text-sm text-[#fff]">Déclarations</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserProfile;
@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
interface ClotureInfo {
cloture: boolean;
details: {
mois_annee: string;
date_debut: string;
date_fin: string;
campus: string | null;
commentaire: string | null;
} | null;
}
interface Cloture {
id: number;
mois_annee: string;
date_debut: string;
date_fin: string;
campus: string | null;
commentaire: string | null;
}
export const useClotureCheck = () => {
const [clotures, setClotures] = useState<Cloture[]>([]);
// Charger toutes les clôtures au démarrage
useEffect(() => {
fetch('/api/clotures')
.then(res => res.json())
.then(data => {
if (data.success) {
setClotures(data.clotures);
}
})
.catch(err => console.error('Erreur chargement clôtures:', err));
}, []);
const isDateCloturee = (dateString: string, campus?: string): boolean => {
const date = new Date(dateString);
return clotures.some(cloture => {
const dateDebut = new Date(cloture.date_debut);
const dateFin = new Date(cloture.date_fin);
// Vérifier si la date est dans la période
const dateInPeriod = date >= dateDebut && date <= dateFin;
// Vérifier le campus si spécifié
const campusMatch = !cloture.campus || !campus || cloture.campus === campus;
return dateInPeriod && campusMatch;
});
};
const getClotureDetails = (dateString: string, campus?: string): Cloture | null => {
const date = new Date(dateString);
return clotures.find(cloture => {
const dateDebut = new Date(cloture.date_debut);
const dateFin = new Date(cloture.date_fin);
const dateInPeriod = date >= dateDebut && date <= dateFin;
const campusMatch = !cloture.campus || !campus || cloture.campus === campus;
return dateInPeriod && campusMatch;
}) || null;
};
return { isDateCloturee, getClotureDetails, clotures };
};
@@ -0,0 +1,451 @@
import React, { createContext, useContext, useState, useEffect, useRef } from "react";
import { msalInstance } from "../msalInstance";
import { loginRequest } from "../Authconfig";
type UserType = {
id?: number;
prenom?: string;
nom?: string;
nom_complet?: string;
email: string;
jobTitle?: string;
department?: string;
officeLocation?: string;
role?: string;
service?: string;
campus?: string;
Contrat?: string;
Campus?: string;
// Nouveaux champs de formateurs_local
Civilite?: string;
Nom_brut?: string;
givenname?: string;
alternateEmailAddress?: string;
surname?: string;
mailnickname?: string;
displayName?: string;
Jobtitle?: string;
departement?: string;
};
type AuthContextType = {
isAuthorized: boolean;
user: UserType | null;
loginWithO365: () => Promise<void>;
logout: () => void;
isLoading: boolean;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthorized, setIsAuthorized] = useState<boolean>(false);
const [user, setUser] = useState<UserType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const initializingRef = useRef(false);
// Log helper avec timestamp
const log = (emoji: string, message: string, data?: any) => {
const timestamp = new Date().toISOString();
if (data !== undefined) {
console.log(`[AUTH ${emoji}] ${timestamp} - ${message}`, data);
} else {
console.log(`[AUTH ${emoji}] ${timestamp} - ${message}`);
}
};
// Gérer le retour de la redirection Microsoft
useEffect(() => {
if (initializingRef.current) {
log('⚠️', 'Initialisation déjà en cours, skip');
return;
}
initializingRef.current = true;
log('🚀', '=== DÉBUT INITIALISATION AUTH CONTEXT (MODE LOCAL) ===');
const handleRedirectResponse = async () => {
try {
log('🔍', 'Vérification de la réponse de redirection Microsoft...');
const response = await msalInstance.handleRedirectPromise();
log('📨', 'Réponse handleRedirectPromise:', response);
if (response) {
log('✅', 'Retour de redirection Microsoft détecté');
log('📧', 'Account username:', response.account.username);
log('📧', 'Account name:', response.account.name);
log('🔑', 'Access token présent:', !!response.accessToken);
await completeLogin(response.account, response.accessToken);
} else {
log('️', 'Pas de redirection Microsoft en cours');
const accounts = msalInstance.getAllAccounts();
log('👥', `Comptes MSAL trouvés: ${accounts.length}`, accounts);
if (accounts.length > 0) {
log('🔄', 'Compte MSAL existe, tentative de reconnexion silencieuse...');
try {
const tokenResponse = await msalInstance.acquireTokenSilent({
...loginRequest,
account: accounts[0]
});
log('✅', 'Token silencieux acquis');
await completeLogin(accounts[0], tokenResponse.accessToken);
} catch (tokenError: any) {
log('⚠️', 'Impossible d\'acquérir token silencieusement:', tokenError.message);
setIsLoading(false);
}
} else {
log('️', 'Aucun compte MSAL, utilisateur non connecté');
setIsLoading(false);
}
}
} catch (error: any) {
log('❌', 'ERREUR lors du traitement de la redirection:', error);
log('❌', 'Message d\'erreur:', error.message);
log('❌', 'Stack:', error.stack);
const url = new URL(window.location.href);
url.searchParams.set('error', 'auth_failed');
url.searchParams.set('error_description', error.message || 'Erreur inconnue');
window.history.replaceState({}, '', url);
setIsLoading(false);
}
};
handleRedirectResponse();
}, []);
const completeLogin = async (account: any, accessToken?: string) => {
let userEmail = '';
let userName = '';
let userCampus = '';
try {
log('🔄', '=== DÉBUT FINALISATION CONNEXION (MODE LOCAL) ===');
log('👤', 'Account username:', account.username);
log('👤', 'Account name:', account.name);
userEmail = account.username;
userName = account.name || '';
// Acquérir le token si nécessaire
let token = accessToken;
if (!token) {
log('🔑', 'Pas de token fourni, acquisition...');
try {
const tokenResponse = await msalInstance.acquireTokenSilent({
...loginRequest,
account: account
});
token = tokenResponse.accessToken;
log('✅', 'Token acquis avec succès');
} catch (tokenError: any) {
log('⚠️', 'Erreur acquisition token silencieux:', tokenError.message);
if (tokenError.name === "InteractionRequiredAuthError") {
log('🔄', 'Interaction requise, redirection pour obtenir le token...');
await msalInstance.acquireTokenRedirect(loginRequest);
return;
}
throw tokenError;
}
}
// Récupérer les infos depuis Microsoft Graph
log('📞', 'Appel Microsoft Graph API...');
const graphRes = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${token}` }
});
if (!graphRes.ok) {
throw new Error(`Erreur Graph API: ${graphRes.status}`);
}
const graphData = await graphRes.json();
log('✅', 'Données Graph reçues:', {
displayName: graphData.displayName,
mail: graphData.mail,
jobTitle: graphData.jobTitle
});
if (graphData.mail) {
userEmail = graphData.mail;
}
userName = graphData.displayName || userName;
log('📧', 'Email final utilisateur:', userEmail);
log('👤', 'Nom final utilisateur:', userName);
// ✅ MODIFICATION: Appel API avec le nouveau mode local
log('📞', `Appel API backend (MODE LOCAL): /api/formateurs?email=${userEmail}`);
const localRes = await fetch(`/api/formateurs?email=${encodeURIComponent(userEmail)}`);
log('📊', `Statut réponse API: ${localRes.status}`);
if (!localRes.ok) {
const errorText = await localRes.text();
log('❌', `Erreur API formateurs (${localRes.status}):`, errorText);
throw new Error(`Erreur API formateurs: ${localRes.status} - ${errorText}`);
}
const localData = await localRes.json();
log('📦', 'Réponse API formateurs (LOCAL):', localData);
let userData: UserType;
// ✅ MODIFICATION: Adapter au format de réponse de l'API locale
if (localData.success && localData.data && localData.data.length > 0) {
const dbUser = localData.data[0];
userCampus = dbUser.Campus || '';
// ✅ Mapper les champs de formateurs_local vers UserType
userData = {
email: dbUser.email || userEmail,
nom_complet: dbUser.nom_complet || userName,
// Utiliser les nouveaux champs de formateurs_local
Nom_brut: dbUser.nom_complet,
givenname: dbUser.nom_complet?.split(' ')[0],
surname: dbUser.nom_complet?.split(' ').slice(1).join(' '),
displayName: dbUser.nom_complet,
Campus: dbUser.Campus,
Contrat: dbUser.Contrat,
campus: dbUser.Campus,
departement: dbUser.departement || 'Non défini',
role: 'formateur'
};
log('✅', 'Utilisateur trouvé dans formateurs_local:', {
email: userData.email,
nom_complet: userData.nom_complet,
campus: userCampus
});
} else if (localData.data && localData.data.length > 0) {
// Fallback si success n'est pas présent
const dbUser = localData.data[0];
userCampus = dbUser.Campus || '';
userData = {
email: dbUser.email || userEmail,
nom_complet: dbUser.nom_complet || userName,
Nom_brut: dbUser.nom_complet,
givenname: dbUser.nom_complet?.split(' ')[0],
surname: dbUser.nom_complet?.split(' ').slice(1).join(' '),
displayName: dbUser.nom_complet,
Campus: dbUser.Campus,
Contrat: dbUser.Contrat,
campus: dbUser.Campus,
departement: dbUser.departement || 'Non défini',
role: 'formateur'
};
log('✅', 'Utilisateur trouvé (sans flag success):', {
email: userData.email,
nom_complet: userData.nom_complet,
campus: userCampus
});
} else {
// ✅ Utilisateur NON TROUVÉ dans formateurs_local
log('❌', 'UTILISATEUR NON AUTORISÉ (pas dans formateurs_local):', userEmail);
log('❌', 'Réponse API complète:', localData);
// Logger l'échec
try {
await fetch('/api/log-connexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
nom: userName,
statut: 'failed',
messageErreur: 'Utilisateur non trouvé dans formateurs_local',
campus: null
})
});
} catch (logError) {
log('⚠️', 'Erreur log connexion échec:', logError);
}
throw new Error('Accès refusé : Vous devez être dans la table formateurs_local pour accéder à l\'application.');
}
// Enrichir les données utilisateur
if (!userData.nom_complet && userName) {
userData.nom_complet = userName;
}
// Extraire nom et prénom si pas déjà présents
if (!userData.prenom || !userData.nom) {
const nomComplet = userData.nom_complet || userName || '';
const parts = nomComplet.split(' ');
if (parts.length >= 2) {
userData.prenom = userData.prenom || parts[0];
userData.nom = userData.nom || parts.slice(1).join(' ');
} else {
userData.prenom = userData.prenom || parts[0] || 'Utilisateur';
userData.nom = userData.nom || '';
}
}
log('✅', 'Données utilisateur finales:', userData);
// Logger la connexion réussie
log('📝', 'Enregistrement du log de connexion...');
try {
await fetch('/api/log-connexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
nom: userData.nom_complet || userName,
statut: 'success',
messageErreur: null,
campus: userCampus
})
});
log('✅', 'Log de connexion enregistré');
} catch (logError) {
log('⚠️', 'Erreur enregistrement log (non bloquant):', logError);
}
// Mettre à jour l'état
log('💾', 'Mise à jour de l\'état React...');
setUser(userData);
setIsAuthorized(true);
setIsLoading(false);
log('✅', '=== CONNEXION FINALISÉE AVEC SUCCÈS (MODE LOCAL) ===');
} catch (error: any) {
log('❌', '=== ERREUR FINALISATION CONNEXION ===');
log('❌', 'Message:', error.message);
log('❌', 'Stack:', error.stack);
if (userEmail) {
try {
await fetch('/api/log-connexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: userEmail,
nom: userName,
statut: 'failed',
messageErreur: error.message || 'Erreur inconnue',
campus: userCampus || null
})
});
} catch (logError) {
log('⚠️', 'Erreur log connexion échec:', logError);
}
}
setUser(null);
setIsAuthorized(false);
setIsLoading(false);
throw error;
}
};
const loginWithO365 = async (): Promise<void> => {
try {
log('🚀', '=== DÉBUT PROCESSUS CONNEXION O365 (MODE LOCAL) ===');
log('⚙️', 'Configuration MSAL:', {
clientId: loginRequest.scopes?.[0]?.includes('User.Read') ? 'OK' : 'Vérifier scopes',
redirectUri: window.location.origin
});
setIsLoading(true);
log('🔄', 'Appel loginRedirect...');
log('🔑', 'Scopes demandés:', loginRequest.scopes);
await msalInstance.loginRedirect(loginRequest);
log('✅', 'Redirection vers Microsoft lancée');
} catch (error: any) {
log('❌', 'ERREUR lors de l\'initialisation de la connexion');
log('❌', 'Message:', error.message);
log('❌', 'Stack:', error.stack);
setIsLoading(false);
try {
await fetch('/api/log-connexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'unknown',
nom: 'unknown',
statut: 'failed',
messageErreur: `Erreur loginWithO365: ${error.message}`,
campus: null
})
});
} catch (logError) {
log('⚠️', 'Erreur log (non bloquant):', logError);
}
throw error;
}
};
const logout = async () => {
try {
log('👋', '=== DÉBUT DÉCONNEXION ===');
const accounts = msalInstance.getAllAccounts();
log('👥', `Comptes à déconnecter: ${accounts.length}`);
setIsAuthorized(false);
setUser(null);
if (accounts.length > 0) {
log('🔄', 'Appel logoutRedirect...');
await msalInstance.logoutRedirect({
account: accounts[0],
postLogoutRedirectUri: window.location.origin
});
log('✅', 'Redirection déconnexion lancée');
} else {
log('️', 'Aucun compte à déconnecter, rechargement page...');
window.location.href = '/';
}
} catch (error: any) {
log('❌', 'Erreur lors de la déconnexion:', error);
window.location.href = '/';
}
};
useEffect(() => {
log('📊', 'État AuthContext:', {
isAuthorized,
userEmail: user?.email || 'null',
isLoading
});
}, [isAuthorized, user, isLoading]);
return (
<AuthContext.Provider value={{ isAuthorized, user, loginWithO365, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth doit être utilisé dans un AuthProvider");
}
return ctx;
};
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import App from './App';
import Login from './components/Login';
import { AuthProvider, useAuth } from './context/AuthContext';
import './index.css';
// ✅ CORRECTION: PrivateRoute avec gestion du loading
const PrivateRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthorized, isLoading } = useAuth();
// Afficher un loader pendant la vérification
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#7e5aa2] mx-auto mb-4"></div>
<p className="text-gray-600">Vérification de la session...</p>
</div>
</div>
);
}
// Rediriger vers login si non autorisé
return isAuthorized ? children : <Navigate to="/" replace />;
};
// ✅ CORRECTION: Composant pour la page d'accueil qui redirige intelligemment
const HomePage: React.FC = () => {
const { isAuthorized, isLoading } = useAuth();
// Pendant le chargement, afficher un loader
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#7e5aa2] mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
// Si autorisé, rediriger vers dashboard, sinon afficher login
return isAuthorized ? <Navigate to="/dashboard" replace /> : <Login />;
};
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* ✅ Route principale qui affiche Login ou redirige vers Dashboard */}
<Route path="/" element={<HomePage />} />
{/* ✅ Route dashboard protégée */}
<Route
path="/dashboard"
element={
<PrivateRoute>
<App />
</PrivateRoute>
}
/>
{/* ✅ Toutes les autres routes redirigent vers la page d'accueil */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</React.StrictMode>
);
+9
View File
@@ -0,0 +1,9 @@
// msalInstance.ts
import { PublicClientApplication } from "@azure/msal-browser";
import { msalConfig } from "./Authconfig";
export const msalInstance = new PublicClientApplication(msalConfig);
// Important : initialiser avant usage
await msalInstance.initialize();
@@ -0,0 +1,9 @@
export interface TimeEntry {
startTime: string;
endTime: string;
date: string;
activityType: 'preparation' | 'correction';
hours: number;
description: string;
createdAt: Date;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />