Files
GTA/project/src/pages/Calendar.jsx

1527 lines
77 KiB
JavaScript

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import Sidebar from '../components/Sidebar';
import { ChevronLeft, ChevronRight, X, Menu, RefreshCw, Search, Filter, Calendar as CalendarIcon } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import NewLeaveRequestModal from '../components/NewLeaveRequestModal';
import { useMsal } from "@azure/msal-react";
import GlobalTutorial from '../components/GlobalTutorial';
const Calendar = () => {
const { user } = useAuth();
const role = user?.role?.toLowerCase();
const userId = user?.id || user?.CollaborateurADId || user?.ID;
const [isFirstLoad, setIsFirstLoad] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [hoveredLeave, setHoveredLeave] = useState(null);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [showFilters, setShowFilters] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
const [selectedEndDate, setSelectedEndDate] = useState(null);
const [isSelectingRange, setIsSelectingRange] = useState(false);
const [contextMenu, setContextMenu] = useState({ show: false, x: 0, y: 0 });
const [preselectedDates, setPreselectedDates] = useState(null);
const [holidays, setHolidays] = useState([]);
const [detailedCounters, setDetailedCounters] = useState(null);
const [teamLeaves, setTeamLeaves] = useState([]);
const [filters, setFilters] = useState({});
const [employeeFilter, setEmployeeFilter] = useState("all");
const [selectedCampus, setSelectedCampus] = useState("all");
const [selectedSociete, setSelectedSociete] = useState("all");
const [selectedService, setSelectedService] = useState("all");
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [showEmployeeList, setShowEmployeeList] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [lastRefresh, setLastRefresh] = useState(new Date());
const [graphToken, setGraphToken] = useState(null);
const { instance, accounts } = useMsal();
const [isMobile, setIsMobile] = useState(false);
const [sseConnected, setSseConnected] = useState(false);
const [toasts, setToasts] = useState([]);
const [initialFiltersSet, setInitialFiltersSet] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const monthNames = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
const dayNamesMobile = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
useEffect(() => {
if (accounts.length > 0) {
const request = {
scopes: ["User.Read", "Mail.Send"],
account: accounts[0],
};
instance.acquireTokenSilent(request)
.then((response) => {
setGraphToken(response.accessToken);
})
.catch((err) => {
console.error("❌ Erreur récupération token Graph:", err);
});
}
}, [accounts, instance]);
const fetchFrenchHolidays = async (year) => {
try {
const response = await fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${year}.json`);
if (!response.ok) throw new Error(`Erreur API: ${response.status}`);
const data = await response.json();
const holidayDates = Object.keys(data).map(dateStr => {
const [year, month, day] = dateStr.split('-').map(Number);
return {
date: new Date(year, month - 1, day),
name: data[dateStr]
};
});
return holidayDates;
} catch (error) {
console.error('Erreur jours fériés:', error);
return [];
}
};
useEffect(() => {
const year = currentDate.getFullYear();
fetchFrenchHolidays(year).then(setHolidays);
}, [currentDate]);
const isHoliday = (date) => {
if (!date) return false;
return holidays.some(holiday =>
holiday.date.getDate() === date.getDate() &&
holiday.date.getMonth() === date.getMonth() &&
holiday.date.getFullYear() === date.getFullYear()
);
};
const getHolidayName = (date) => {
const holiday = holidays.find(h =>
h.date.getDate() === date.getDate() &&
h.date.getMonth() === date.getMonth() &&
h.date.getFullYear() === date.getFullYear()
);
return holiday?.name || '';
};
const fetchDetailedCounters = async () => {
try {
const response = await fetch(`/api/getDetailedLeaveCounters?user_id=${userId}`);
const data = await response.json();
if (data.success) {
setDetailedCounters(data.data);
}
} catch (error) {
console.error('💥 Erreur compteurs:', error);
}
};
const loadTeamLeaves = async () => {
if (userId) {
try {
let url = `/api/getTeamLeaves?user_id=${userId}&role=${user.role}`;
console.log("📡 AVANT construction URL:", {
selectedSociete,
selectedCampus,
selectedService
});
if (role === 'president' || role === 'rh' || role === 'admin' ||
role === 'directeur de campus' || role === 'directrice de campus' ||
role === 'collaborateur' || role === 'collaboratrice' || role === 'apprenti') {
// ⭐ TOUJOURS envoyer les paramètres
url += `&selectedSociete=${encodeURIComponent(selectedSociete || 'all')}`;
url += `&selectedCampus=${encodeURIComponent(selectedCampus || 'all')}`;
url += `&selectedService=${encodeURIComponent(selectedService || 'all')}`;
}
console.log("📡 URL finale:", url);
const response = await fetch(url);
const data = await response.json();
console.log("📡 Réponse API:", {
employees: data.filters?.employees?.length,
leaves: data.leaves?.length
});
if (data.success) {
setTeamLeaves(data.leaves || []);
setFilters(data.filters || {});
}
} catch (error) {
console.error('Erreur récupération congés équipe:', error);
}
}
};
const refreshAllData = useCallback(async () => {
if (!userId) return;
if (!isLoading) setIsRefreshing(true);
try {
await Promise.all([
fetchDetailedCounters(),
loadTeamLeaves()
]);
setLastRefresh(new Date());
} catch (error) {
console.error('❌ Erreur lors du rafraîchissement:', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [userId, selectedSociete, selectedCampus, selectedService]);
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
const newToast = { id, message, type };
setToasts(prev => [...prev, newToast]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
}, []);
const handleSSEEvent = useCallback((eventData) => {
console.log('📅 Événement SSE reçu:', eventData.type);
if (eventData.type === 'demande-validated') {
showToast(
`✅ Demande validée: ${eventData.typeConge}`,
'success'
);
console.log(`🎨 Demande ${eventData.demandeId} validée - Type: ${eventData.typeConge}`);
refreshAllData();
}
}, [showToast, refreshAllData]);
useEffect(() => {
if (!userId) {
console.log('❌ userId manquant, SSE non démarré');
setSseConnected(false);
return;
}
console.log('🔌 Initialisation SSE avec userId:', userId);
const eventSource = new EventSource(
`/api/sse?user_id=${userId}`
);
const handleValidated = (event) => {
try {
const eventData = JSON.parse(event.data);
console.log('📨 SSE demande-validated reçu:', eventData);
setSseConnected(true);
handleSSEEvent(eventData);
} catch (error) {
console.error('❌ Erreur parsing SSE:', error);
}
};
const handleHeartbeat = () => {
console.log('💓 SSE Heartbeat');
setSseConnected(true);
};
const handleError = (error) => {
console.error('❌ Erreur SSE:', error);
setSseConnected(false);
eventSource.close();
};
eventSource.addEventListener('demande-validated', handleValidated);
eventSource.addEventListener('ping', handleHeartbeat);
eventSource.onerror = handleError;
return () => {
console.log('🔌 Fermeture SSE');
eventSource.removeEventListener('demande-validated', handleValidated);
eventSource.removeEventListener('ping', handleHeartbeat);
eventSource.close();
setSseConnected(false);
};
}, [userId, handleSSEEvent]);
useEffect(() => {
refreshAllData();
}, [refreshAllData]);
useEffect(() => {
if (isFirstLoad && filters.defaultCampus && (role === 'directeur de campus' || role === 'directrice de campus')) {
console.log('🏢 Initialisation campus par défaut:', filters.defaultCampus);
setSelectedCampus(filters.defaultCampus);
setIsFirstLoad(false);
}
}, [filters.defaultCampus, isFirstLoad, role]);
// ⭐ Initialisation des filtres par défaut pour collaborateur/apprenti (UNE SEULE FOIS)
useEffect(() => {
if (!initialFiltersSet &&
filters.defaultCampus &&
(role === 'collaborateur' || role === 'collaboratrice' || role === 'apprenti')) {
console.log('🎯 Initialisation des filtres par défaut pour collaborateur');
console.log('📍 Valeurs reçues du backend:', {
defaultSociete: filters.defaultSociete,
defaultCampus: filters.defaultCampus,
defaultService: filters.defaultService
});
setSelectedSociete(filters.defaultSociete || 'all');
setSelectedCampus(filters.defaultCampus || 'all');
setSelectedService(filters.defaultService || 'all');
setInitialFiltersSet(true);
}
}, [filters.defaultCampus, filters.defaultService, filters.defaultSociete, initialFiltersSet, role]);
// ⭐ Rechargement quand les filtres changent (TOUJOURS)
useEffect(() => {
if (role === 'president' || role === 'rh' || role === 'admin' ||
role === 'directeur de campus' || role === 'directrice de campus' ||
role === 'collaborateur' || role === 'collaboratrice' || role === 'apprenti') {
console.log("🔄 Rechargement données:", {
societe: selectedSociete,
campus: selectedCampus,
service: selectedService,
role: role
});
loadTeamLeaves();
}
}, [selectedSociete, selectedCampus, selectedService, role]);
const getDaysInMonth = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const days = [];
for (let day = 1; day <= daysInMonth; day++) {
const currentDay = new Date(year, month, day);
const dayOfWeek = currentDay.getDay();
if ((dayOfWeek >= 1 && dayOfWeek <= 6) || dayOfWeek === 0) {
days.push(currentDay);
}
}
return days;
};
const isSunday = (date) => {
return date.getDay() === 0;
};
const getDaysInMonthMobile = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = (firstDay.getDay() + 6) % 7;
const days = [];
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
for (let day = 1; day <= daysInMonth; day++) {
const currentDay = new Date(year, month, day);
const dayOfWeek = currentDay.getDay();
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
days.push(currentDay);
}
}
return days;
};
// ⭐ SIMPLIFIÉ : Le backend filtre déjà
const getBaseFilteredLeaves = () => {
return teamLeaves;
};
// ⭐ SIMPLIFIÉ : Pas de cas spécial pour collaborateur
const getAllEmployees = () => {
if (!filters.employees || filters.employees.length === 0) {
return [];
}
const employeeList = filters.employees.map(emp => ({
name: typeof emp === 'string' ? emp : emp.name,
campus: emp.campus || '',
societe: emp.societe || '',
service: emp.service || ''
}));
// Dédoublonner
const uniqueEmployees = [];
const seenNames = new Set();
for (const emp of employeeList) {
if (!seenNames.has(emp.name)) {
seenNames.add(emp.name);
uniqueEmployees.push(emp);
}
}
return uniqueEmployees;
};
const getDisplayedEmployees = () => {
const allEmployees = getAllEmployees();
let filteredEmployees = allEmployees;
if (selectedEmployees.length > 0) {
filteredEmployees = allEmployees.filter(emp => selectedEmployees.includes(emp.name));
}
const shouldAutoSort = (
['president', 'rh', 'admin', 'directeur de campus', 'directrice de campus'].includes(role) &&
selectedCampus === 'all' &&
selectedSociete === 'all' &&
selectedService === 'all'
);
const shouldSortByService = (
['collaborateur', 'collaboratrice', 'apprenti'].includes(role) &&
selectedService === 'all'
);
if (shouldAutoSort) {
console.log("🔄 Tri automatique par société → campus → service → nom");
return filteredEmployees.sort((a, b) => {
const isAMultiCampus = a.societe === 'Ensup Solution & Support' || a.societe === 'Ensup Solution et Support';
const isBMultiCampus = b.societe === 'Ensup Solution & Support' || b.societe === 'Ensup Solution et Support';
if (isAMultiCampus && !isBMultiCampus) return -1;
if (!isAMultiCampus && isBMultiCampus) return 1;
if (isAMultiCampus && isBMultiCampus) {
const campusOrder = {
'CGY': 1,
'Cergy': 1,
'SQY': 2,
'Saint-Quentin': 2,
'Marseille': 3,
'Nantes': 4,
'Multi-campus': 5,
'N/A': 6
};
const orderA = campusOrder[a.campus] || 999;
const orderB = campusOrder[b.campus] || 999;
if (orderA !== orderB) return orderA - orderB;
const serviceCompare = (a.service || '').localeCompare(b.service || '');
if (serviceCompare !== 0) return serviceCompare;
return a.name.localeCompare(b.name);
}
const campusOrder = {
'CGY': 1,
'Cergy': 1,
'SQY': 2,
'Saint-Quentin': 2,
'Marseille': 3,
'Nantes': 4
};
const orderA = campusOrder[a.campus] || 999;
const orderB = campusOrder[b.campus] || 999;
if (orderA !== orderB) return orderA - orderB;
const societeCompare = (a.societe || '').localeCompare(b.societe || '');
if (societeCompare !== 0) return societeCompare;
const serviceCompare = (a.service || '').localeCompare(b.service || '');
if (serviceCompare !== 0) return serviceCompare;
return a.name.localeCompare(b.name);
});
}
if (shouldSortByService) {
console.log("🔄 Tri par service pour collaborateur");
return filteredEmployees.sort((a, b) => {
const serviceCompare = (a.service || '').localeCompare(b.service || '');
if (serviceCompare !== 0) return serviceCompare;
return a.name.localeCompare(b.name);
});
}
return filteredEmployees.sort((a, b) => a.name.localeCompare(b.name));
};
const getDisplayedLeaves = () => {
const baseFiltered = getBaseFilteredLeaves();
const displayedEmployees = getDisplayedEmployees();
const displayedNames = displayedEmployees.map(emp => emp.name);
return baseFiltered.filter(leave => displayedNames.includes(leave.employeename));
};
// ⭐ useMemo simplifié
const allEmployeesData = useMemo(() => {
return getAllEmployees();
}, [filters.employees]);
useEffect(() => {
const availableNames = allEmployeesData.map(emp => emp.name);
setSelectedEmployees(prev => {
const filtered = prev.filter(name => availableNames.includes(name));
if (filtered.length !== prev.length) {
console.log("🧹 Nettoyage employés:", {
avant: prev.length,
après: filtered.length
});
return filtered;
}
return prev;
});
}, [allEmployeesData]);
useEffect(() => {
console.log("👥 Employés disponibles (après filtres):", allEmployeesData.length);
console.log("✅ Employés sélectionnés:", selectedEmployees.length);
console.log("📊 Employés affichés dans le tableau:", getDisplayedEmployees().length);
}, [allEmployeesData, selectedEmployees]);
const toggleEmployee = (employeeName) => {
setSelectedEmployees(prev => {
if (prev.includes(employeeName)) {
return prev.filter(name => name !== employeeName);
} else {
return [...prev, employeeName];
}
});
};
const selectAllEmployees = () => {
const allNames = allEmployeesData.map(emp => emp.name);
setSelectedEmployees(allNames);
};
const deselectAllEmployees = () => {
setSelectedEmployees([]);
};
const parseLocalDate = (dateStr) => {
if (!dateStr) return null;
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(year, month - 1, day);
};
const getLeaveForEmployeeOnDate = (employeeName, date) => {
if (!date) return null;
const displayedLeaves = getDisplayedLeaves();
return displayedLeaves.find(leave => {
if (leave.employeename !== employeeName) return false;
const start = parseLocalDate(leave.startdate);
const end = parseLocalDate(leave.enddate);
return date >= start && date <= end;
});
};
const hasLeave = (date) => {
if (!date) return false;
const displayedLeaves = getDisplayedLeaves();
return displayedLeaves.some(leave => {
const start = parseLocalDate(leave.startdate);
const end = parseLocalDate(leave.enddate);
return date >= start && date <= end;
});
};
const getLeaveColor = (leave) => {
if (!leave) return { tailwindClass: '', hexColor: '' };
const status = leave.statut?.toLowerCase();
const type = leave.type?.toLowerCase();
if (status === 'en attente' || status === 'pending' || status === 'en attente de validation') {
return { tailwindClass: 'bg-orange-400', hexColor: '#fb923c' };
}
if (type) {
if (type.toLowerCase().includes('récupération') ||
type.toLowerCase().includes('recuperation') ||
type.toLowerCase().includes('recup') ||
type.toLowerCase().includes('récup')) {
return { tailwindClass: '', hexColor: '#d946ef' };
}
if (type.includes('formation')) {
return { tailwindClass: 'bg-blue-400', hexColor: '#60a5fa' };
}
if (type.includes('cp') || type.includes('congé') || type.includes('conge') || type.includes('payé') || type.includes('paye')) {
return { tailwindClass: 'bg-green-400', hexColor: '#4ade80' };
}
if (type.includes('rtt')) {
return { tailwindClass: 'bg-blue-300', hexColor: '#93c5fd' };
}
}
return { tailwindClass: 'bg-green-400', hexColor: '#4ade80' };
};
const handleMouseMove = (e) => {
if (!isMobile) {
setMousePosition({ x: e.clientX, y: e.clientY });
}
};
const navigateMonth = (direction) => {
setCurrentDate(prev => {
const newDate = new Date(prev);
if (direction === 'prev') {
newDate.setMonth(prev.getMonth() - 1);
} else {
newDate.setMonth(prev.getMonth() + 1);
}
return newDate;
});
};
useEffect(() => {
const handleClickOutside = () => {
if (contextMenu.show) {
setContextMenu({ show: false, x: 0, y: 0 });
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [contextMenu.show]);
const isPastDate = (date) => {
if (!date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
};
const isSelected = (date) => {
if (!date || !selectedDate) return false;
if (selectedEndDate) {
return date >= selectedDate && date <= selectedEndDate;
}
return date.toDateString() === selectedDate.toDateString();
};
const isSaturday = (date) => {
return date.getDay() === 6;
};
const calculateWorkingDays = (start, end) => {
if (!start || !end) return 0;
let workingDays = 0;
const current = new Date(start);
while (current <= end) {
const dayOfWeek = current.getDay();
if (dayOfWeek >= 1 && dayOfWeek <= 5 && !isHoliday(current)) {
workingDays++;
}
current.setDate(current.getDate() + 1);
}
return workingDays;
};
const checkIfRangeIncludesSaturday = (start, end) => {
const current = new Date(start);
while (current <= end) {
if (current.getDay() === 6) return true;
current.setDate(current.getDate() + 1);
}
return false;
};
const handleDateClick = (date) => {
if (!date || isPastDate(date) || isHoliday(date)) return;
if (!selectedDate) {
setSelectedDate(date);
setSelectedEndDate(null);
setIsSelectingRange(true);
} else if (isSelectingRange && !selectedEndDate) {
if (date >= selectedDate) {
setSelectedEndDate(date);
setIsSelectingRange(false);
setTimeout(() => {
setContextMenu({
show: true,
x: window.innerWidth / 2,
y: window.innerHeight / 2
});
}, 100);
} else {
setSelectedDate(date);
setSelectedEndDate(null);
}
} else {
setSelectedDate(date);
setSelectedEndDate(null);
setIsSelectingRange(true);
}
};
const handleContextMenu = (e, date) => {
e.preventDefault();
if (!date || isPastDate(date) || isHoliday(date) || !isSelected(date)) return;
setContextMenu({
show: true,
x: e.clientX,
y: e.clientY
});
};
const handleTypeSelection = (type) => {
if (!selectedDate) return;
const startDate = selectedDate;
const endDate = selectedEndDate || selectedDate;
const includesSaturday = checkIfRangeIncludesSaturday(startDate, endDate);
setPreselectedDates({
startDate: startDate,
endDate: endDate,
type: type
});
setShowNewRequestModal(true);
setContextMenu({ show: false, x: 0, y: 0 });
setSelectedDate(null);
setSelectedEndDate(null);
setIsSelectingRange(false);
};
const cancelSelection = () => {
setSelectedDate(null);
setSelectedEndDate(null);
setIsSelectingRange(false);
setContextMenu({ show: false, x: 0, y: 0 });
showToast('❌ Sélection annulée', 'info');
};
const getSelectedDays = () => {
if (!selectedDate) return 0;
if (selectedEndDate) {
return calculateWorkingDays(selectedDate, selectedEndDate);
}
return 1;
};
const formatDateToString = (date) => {
if (!date) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const days = isMobile ? getDaysInMonthMobile() : getDaysInMonth();
const employees = getDisplayedEmployees();
const allEmployees = allEmployeesData;
const totalPTO = detailedCounters
? (detailedCounters.cpN?.solde || 0) + (detailedCounters.cpN1?.solde || 0) + (detailedCounters.rttN?.solde || 0)
: 0;
const canViewAllFilters = ['president', 'rh', 'admin', 'directeur de campus', 'directrice de campus', 'collaborateur', 'collaboratrice', 'apprenti'].includes(role);
const canViewCampusFilters = ['president', 'rh', 'admin', 'directeur de campus', 'directrice de campus'].includes(role);
const activeFiltersCount = [
employeeFilter !== "all" ? employeeFilter : null,
selectedCampus !== "all" ? selectedCampus : null,
selectedSociete !== "all" ? selectedSociete : null,
selectedService !== "all" ? selectedService : null,
selectedEmployees.length > 0 ? `${selectedEmployees.length} collab` : null,
].filter(Boolean).length;
const resetToDefaultFilters = () => {
if (['collaborateur', 'collaboratrice', 'apprenti'].includes(role)) {
const userEmployee = filters.employees?.find(emp =>
emp.name === `${user.prenom} ${user.nom}`
);
if (userEmployee) {
setSelectedSociete(userEmployee.societe || 'all');
setSelectedCampus(userEmployee.campus || 'all');
setSelectedService(userEmployee.service || 'all');
}
} else {
setSelectedSociete('all');
setSelectedCampus('all');
setSelectedService('all');
}
setSelectedEmployees([]);
};
const renderLeaveCell = (leave) => {
if (!leave) return null;
const colorObj = getLeaveColor(leave);
const bgColor = colorObj.hexColor || '#4ade80';
let details;
try {
if (typeof leave.detailsconges === 'string') {
details = JSON.parse(leave.detailsconges);
} else {
details = leave.detailsconges || [];
}
} catch (e) {
console.error('Erreur parsing detailsconges:', e, leave.detailsconges);
details = [];
}
const isPeriodePartielle = Array.isArray(details) && details.some(d =>
d.periode === 'Matin' || d.periode === 'Après-midi'
);
if (isPeriodePartielle) {
const hasMatin = details?.some(d => d.periode === 'Matin');
const hasApresMidi = details?.some(d => d.periode === 'Après-midi');
return (
<div className="relative h-6 w-6 mx-auto rounded overflow-hidden cursor-pointer">
{hasMatin && <div
className="absolute top-0 left-0 right-0 h-3"
style={{ backgroundColor: bgColor }}
></div>}
{hasApresMidi && <div
className="absolute bottom-0 left-0 right-0 h-3"
style={{ backgroundColor: bgColor }}
></div>}
{!hasMatin && !hasApresMidi && <div
className="h-full w-full"
style={{ backgroundColor: bgColor }}
></div>}
</div>
);
}
return (
<div
className="h-6 w-6 mx-auto rounded cursor-pointer"
style={{ backgroundColor: bgColor }}
></div>
);
};
return (
<div className="flex h-screen bg-gray-50" onMouseMove={handleMouseMove}>
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
{/* Mobile header */}
<div className="lg:hidden fixed top-0 left-0 right-0 bg-white shadow-sm z-40 px-4 py-3">
<div className="flex items-center justify-between">
<button onClick={() => setSidebarOpen(true)} className="text-gray-600">
<Menu className="w-6 h-6" />
</button>
<h1 className="text-lg font-semibold text-gray-800">Calendrier</h1>
<button onClick={() => setShowFilters(!showFilters)} className="text-gray-600">
<Filter className="w-6 h-6" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 overflow-auto pt-16 lg:pt-0">
<div className="max-w-7xl mx-auto px-4 lg:px-8 py-6">
{/* Header */}
<div className="hidden lg:flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">Calendrier des congés</h1>
{sseConnected && (
<span className="flex items-center gap-1 text-xs text-green-600">
<div className="w-2 h-2 bg-green-600 rounded-full animate-pulse"></div>
Connecté
</span>
)}
</div>
</div>
{/* Selection mode banner */}
{isSelectingRange && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between" data-tour="mode-selection">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm font-medium text-blue-900">
Mode sélection activé
</p>
<p className="text-xs text-blue-700">
{selectedDate ?
`Début: ${selectedDate.toLocaleDateString('fr-FR')} - Cliquez sur la date de fin` :
'Cliquez sur une date de début'
}
</p>
</div>
</div>
<button
onClick={cancelSelection}
className="px-3 py-1.5 bg-white border border-blue-300 text-blue-700 rounded text-sm hover:bg-blue-50"
>
Annuler
</button>
</div>
)}
{/* Selected dates info */}
{selectedDate && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-3 lg:p-4">
<div className="flex flex-col gap-2">
<div>
<p className="text-blue-800 font-medium text-sm lg:text-base">
{selectedEndDate ? 'Plage sélectionnée' : 'Date sélectionnée'} :
{selectedDate.toLocaleDateString('fr-FR')}
{selectedEndDate && ` - ${selectedEndDate.toLocaleDateString('fr-FR')}`}
</p>
<p className="text-blue-600 text-xs lg:text-sm">
{getSelectedDays()} jour{getSelectedDays() > 1 ? 's' : ''} ouvré{getSelectedDays() > 1 ? 's' : ''}
{isSelectingRange && !selectedEndDate && (
<span> (cliquez sur une autre date pour créer une plage)</span>
)}
</p>
</div>
</div>
</div>
)}
{/* Filters section */}
<div className="mb-4 bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
data-tour="filtres-btn"
>
<Filter className="w-4 h-4" />
<span className="text-sm font-medium">
Filtres {activeFiltersCount > 0 && `(${activeFiltersCount})`}
</span>
</button>
<div className="flex items-center gap-3">
{activeFiltersCount > 0 && (
<button
onClick={resetToDefaultFilters}
className="text-sm text-blue-600 hover:text-blue-700"
>
{['collaborateur', 'collaboratrice', 'apprenti'].includes(role)
? 'Réinitialiser aux valeurs par défaut'
: 'Réinitialiser les filtres'
}
</button>
)}
<button
onClick={refreshAllData}
disabled={isRefreshing}
className="p-2 text-gray-600 hover:bg-gray-100 rounded border border-gray-300"
data-tour="refresh-btn"
>
<RefreshCw className={`w-5 h-5 mx-auto ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{showFilters && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pt-4 border-t border-gray-200">
{/* Société filter */}
{canViewAllFilters && filters.societes && filters.societes.length > 0 && (
<div data-tour="filtre-societe">
<label className="block text-sm font-medium text-gray-700 mb-2">
Société {selectedSociete !== 'all' && <span className="text-xs text-gray-500 ml-1">(filtré)</span>}
</label>
<select
value={selectedSociete}
onChange={(e) => {
console.log("📍 Changement société:", e.target.value);
setSelectedSociete(e.target.value);
setSelectedCampus('all');
setSelectedService('all');
setSelectedEmployees([]);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="all">Toutes les sociétés</option>
{filters.societes?.map((societe, index) => (
<option key={index} value={societe}>{societe}</option>
))}
</select>
</div>
)}
{/* Campus filter */}
{filters.campus && filters.campus.length > 0 && (
<div data-tour="filtre-campus">
<label className="block text-sm font-medium text-gray-700 mb-2">
Campus
{selectedSociete !== 'all' && <span className="text-xs text-gray-500 ml-1">(filtré par {selectedSociete})</span>}
</label>
<select
value={selectedCampus}
onChange={(e) => {
console.log("📍 Changement campus:", e.target.value);
setSelectedCampus(e.target.value);
setSelectedService('all');
setSelectedEmployees([]);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="all">Tous les campus</option>
{filters.campus?.map((campus, index) => (
<option key={index} value={campus}>{campus}</option>
))}
</select>
</div>
)}
{/* Service filter */}
{filters.services && filters.services.length > 0 && (
<div data-tour="filtre-service">
<label className="block text-sm font-medium text-gray-700 mb-2">
Service
{(selectedSociete !== 'all' || selectedCampus !== 'all') && <span className="text-xs text-gray-500 ml-1">(filtré)</span>}
</label>
<select
value={selectedService}
onChange={(e) => {
console.log("📍 Changement service:", e.target.value);
setSelectedService(e.target.value);
setSelectedEmployees([]);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
>
<option value="all">Tous les services</option>
{filters.services?.map((service, index) => (
<option key={index} value={service}>{service}</option>
))}
</select>
</div>
)}
{/* Employee selection */}
<div className="col-span-full" data-tour="selection-collaborateurs">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">
Collaborateurs à afficher
{allEmployeesData.length > 0 && (
<span className="ml-2 text-xs text-gray-500">
({allEmployeesData.length} disponible{allEmployeesData.length > 1 ? 's' : ''})
</span>
)}
</label>
<button
onClick={() => setShowEmployeeList(!showEmployeeList)}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
{showEmployeeList ? 'Masquer' : 'Sélectionner'}
</button>
</div>
{selectedEmployees.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{selectedEmployees.map((empName, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"
>
{empName}
<button
onClick={() => toggleEmployee(empName)}
className="hover:text-blue-900"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{showEmployeeList && (
<div className="border border-gray-300 rounded-lg p-3 bg-gray-50 max-h-60 overflow-y-auto">
{allEmployeesData.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
Aucun collaborateur trouvé avec les filtres actuels
</p>
) : (
<>
<div className="flex gap-2 mb-3">
<button
onClick={selectAllEmployees}
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
Tout sélectionner
</button>
<button
onClick={deselectAllEmployees}
className="px-3 py-1 bg-gray-300 text-gray-700 text-xs rounded hover:bg-gray-400"
>
Tout désélectionner
</button>
</div>
<div className="space-y-2">
{allEmployeesData.map((employee, idx) => (
<label
key={idx}
className="flex items-center gap-2 p-2 hover:bg-white rounded cursor-pointer"
>
<input
type="checkbox"
checked={selectedEmployees.includes(employee.name)}
onChange={() => toggleEmployee(employee.name)}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<div className="text-sm text-gray-700">{employee.name}</div>
<div className="text-xs text-gray-500">
{employee.service}
{canViewCampusFilters && employee.societe && (
<> {employee.societe}</>
)}
{(role === 'president' || role === 'rh' || role === 'admin') && employee.campus && (
<> {employee.campus}</>
)}
{(role === 'directeur de campus' || role === 'directrice de campus') && employee.societe === 'Ensup Solution & Support' && employee.campus !== 'N/A' && (
<> {employee.campus}</>
)}
</div>
</div>
</label>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Calendar */}
{!isMobile && (
<div className="bg-white rounded-lg border overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
{/* Month navigation */}
<div className="flex items-center justify-center py-4 px-4 border-b bg-gray-50" data-tour="navigation-mois">
<button onClick={() => navigateMonth('prev')} className="p-2 hover:bg-gray-200 rounded">
<ChevronLeft className="w-5 h-5 text-blue-600" />
</button>
<div className="mx-8 text-center min-w-[200px]">
<span className="text-lg font-semibold">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
</div>
<button onClick={() => navigateMonth('next')} className="p-2 hover:bg-gray-200 rounded">
<ChevronRight className="w-5 h-5 text-blue-600" />
</button>
</div>
{/* Calendar table */}
<table className="w-full border-collapse" data-tour="calendar-grid">
<thead>
<tr className="border-b bg-gray-50">
<th className="sticky left-0 bg-gray-50 z-20 border-r px-4 py-3 text-left w-48">
<span className="text-sm font-semibold">
Collaborateurs ({employees.length}{selectedEmployees.length > 0 && `/${allEmployees.length}`})
</span>
</th>
{days.map((date, index) => {
const dayName = date.getDay() === 0 ? 'Dim' : dayNames[date.getDay() - 1];
const inRange = isSelected(date);
const past = isPastDate(date);
const holiday = isHoliday(date);
const saturday = isSaturday(date);
const sunday = isSunday(date);
return (
<th
key={index}
className={`border-r px-2 py-2 text-center min-w-[40px] ${holiday ? 'bg-gray-600 text-white' :
sunday ? 'bg-gray-300' :
saturday ? 'bg-gray-300' :
inRange ? 'bg-blue-100' :
past ? 'bg-gray-100' :
'bg-gray-50'
}`}
title={
holiday ? getHolidayName(date) :
sunday ? 'Dimanche - Non sélectionnable' :
saturday ? 'Samedi - Non sélectionnable' :
''
}
>
<div className={`text-xs font-normal ${holiday ? 'text-white' :
sunday ? 'text-gray-500' :
saturday ? 'text-gray-500' :
past ? 'text-gray-400' :
'text-gray-600'
}`}>
{dayName}
</div>
<div className={`text-sm font-medium ${holiday ? 'text-white' :
sunday ? 'text-gray-500' :
saturday ? 'text-gray-500' :
inRange ? 'text-blue-700' :
past ? 'text-gray-400' :
'text-gray-900'
}`}>
{date.getDate()}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{employees.length === 0 ? (
<tr>
<td colSpan={days.length + 1} className="text-center py-12 text-gray-500">
{allEmployees.length === 0
? 'Aucun collaborateur trouvé'
: 'Aucun collaborateur sélectionné - Cliquez sur "Sélectionner" pour choisir'
}
</td>
</tr>
) : (
employees.map((employee, empIndex) => (
<tr key={empIndex} className="border-b hover:bg-gray-50">
<td className="sticky left-0 bg-white z-10 border-r px-4 py-3">
<div className="font-medium text-sm">{employee.name}</div>
<div className="text-xs text-gray-500 mt-0.5">
{employee.service}
{canViewCampusFilters && employee.societe && (
<> {employee.societe}</>
)}
{(role === 'president' || role === 'rh' || role === 'admin') && employee.campus && (
<> {employee.campus}</>
)}
{(role === 'directeur de campus' || role === 'directrice de campus') && employee.societe === 'Ensup Solution & Support' && employee.campus !== 'N/A' && (
<> {employee.campus}</>
)}
</div>
</td>
{days.map((date, dayIndex) => {
const leave = getLeaveForEmployeeOnDate(employee.name, date);
const inRange = isSelected(date);
const past = isPastDate(date);
const holiday = isHoliday(date);
const saturday = isSaturday(date);
const sunday = isSunday(date);
const isToday = date.toDateString() === new Date().toDateString();
const weekendAlreadyHasLeave = (saturday || sunday) && leave;
let details, hasMatin = false, hasApresMidi = false;
if (weekendAlreadyHasLeave) {
try {
if (typeof leave.detailsconges === 'string') {
details = JSON.parse(leave.detailsconges);
} else if (Array.isArray(leave.detailsconges)) {
details = leave.detailsconges;
}
hasMatin = details.some(d => d.periode === 'Matin');
hasApresMidi = details.some(d => d.periode === 'Après-midi');
} catch (e) {
console.error('Erreur parsing:', e);
}
}
return (
<td
key={dayIndex}
className={`border-r px-1 py-3 text-center ${isToday ? 'bg-cyan-50' :
inRange ? 'bg-blue-100' :
past ? 'bg-gray-50 opacity-60' :
holiday ? 'bg-gray-600' :
(sunday || saturday) && !weekendAlreadyHasLeave ? 'bg-gray-200' :
weekendAlreadyHasLeave ? 'cursor-not-allowed' :
''
}`}
onClick={() => {
if (!leave && employee.name === `${user.prenom} ${user.nom}` && !past && !holiday && !sunday && !saturday) {
handleDateClick(date);
}
}}
onContextMenu={(e) => handleContextMenu(e, date)}
onMouseEnter={() => !isMobile && leave && setHoveredLeave({ employee, leave, date })}
onMouseLeave={() => setHoveredLeave(null)}
title={
weekendAlreadyHasLeave
? `${saturday ? 'Samedi' : 'Dimanche'} saisi : ${leave?.type}${hasMatin ? ' - Matin' : ''}${hasApresMidi ? ' - Après-midi' : ''} - Non modifiable`
: isHoliday(date)
? getHolidayName(date)
: sunday
? 'Dimanche - Non sélectionnable'
: saturday
? 'Samedi - Non sélectionnable'
: ''
}
>
{holiday ? (
<div className="h-6 w-6 mx-auto bg-gray-700 rounded" title={getHolidayName(date)}></div>
) : sunday ? (
<div className="relative h-6 w-6 mx-auto rounded overflow-hidden cursor-not-allowed">
{weekendAlreadyHasLeave ? (
<>
{hasMatin && <div className="absolute top-0 left-0 right-0 h-3" style={{ backgroundColor: '#d946ef' }}></div>}
{hasApresMidi && <div className="absolute bottom-0 left-0 right-0 h-3" style={{ backgroundColor: '#d946ef' }}></div>}
{!hasMatin && !hasApresMidi && <div className="h-full w-full" style={{ backgroundColor: '#d946ef' }}></div>}
</>
) : (
<div className="h-full w-full" style={{ backgroundColor: '#d1d5db' }}></div>
)}
</div>
) : saturday ? (
<div className="relative h-6 w-6 mx-auto rounded overflow-hidden cursor-not-allowed">
{weekendAlreadyHasLeave ? (
<>
{hasMatin && <div className="absolute top-0 left-0 right-0 h-3" style={{ backgroundColor: '#d946ef' }}></div>}
{hasApresMidi && <div className="absolute bottom-0 left-0 right-0 h-3" style={{ backgroundColor: '#d946ef' }}></div>}
{!hasMatin && !hasApresMidi && <div className="h-full w-full" style={{ backgroundColor: '#d946ef' }}></div>}
</>
) : (
<div className="h-full w-full" style={{ backgroundColor: '#d1d5db' }}></div>
)}
</div>
) : leave ? (
renderLeaveCell(leave)
) : employee.name === `${user.prenom} ${user.nom}` && !past && !holiday ? (
<div className={`h-6 w-6 mx-auto rounded cursor-pointer transition-all ${inRange ? 'bg-blue-500 scale-110' : 'bg-gray-100 hover:bg-blue-200 hover:scale-105'}`}></div>
) : (
<div className="h-6 w-6 mx-auto bg-gray-100 rounded"></div>
)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-3 lg:gap-6 p-4 border-t border-gray-100 flex-wrap text-xs lg:text-sm" data-tour="legende">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-cyan-50 border border-cyan-200 rounded"></div>
<span className="text-gray-600">Aujourd'hui</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-400 rounded"></div>
<span className="text-gray-600">Validé</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-orange-400 rounded"></div>
<span className="text-gray-600">En attente</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-400 rounded"></div>
<span className="text-gray-600">Formation</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-400 rounded"></div>
<span className="text-gray-600">Samedi/Dimanche </span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-700 rounded"></div>
<span className="text-gray-600">Jours fériés</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: '#d946ef' }}></div>
<span className="text-gray-600">JPO/SF</span>
</div>
</div>
</div>
)}
{/* Mobile calendar - TO BE IMPLEMENTED IF NEEDED */}
</div>
</div>
{/* Context menu */}
{contextMenu.show && (
<div
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50 min-w-[200px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<div className="px-3 py-2 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">
{getSelectedDays()} jour{getSelectedDays() > 1 ? 's' : ''} sélectionné{getSelectedDays() > 1 ? 's' : ''}
</p>
</div>
<button
onClick={() => handleTypeSelection('CP')}
className="w-full text-left px-3 py-2 hover:bg-green-50 rounded text-sm flex items-center gap-2"
>
<div className="w-3 h-3 bg-green-400 rounded"></div>
Congé payé (CP)
</button>
<button
onClick={() => handleTypeSelection('RTT')}
className="w-full text-left px-3 py-2 hover:bg-blue-50 rounded text-sm flex items-center gap-2"
>
<div className="w-3 h-3 bg-blue-300 rounded"></div>
RTT
</button>
<button
onClick={() => handleTypeSelection('Formation')}
className="w-full text-left px-3 py-2 hover:bg-purple-50 rounded text-sm flex items-center gap-2"
>
<div className="w-3 h-3 bg-purple-400 rounded"></div>
Formation
</button>
<button
onClick={() => handleTypeSelection('Autre')}
className="w-full text-left px-3 py-2 hover:bg-gray-50 rounded text-sm flex items-center gap-2"
>
<div className="w-3 h-3 bg-gray-400 rounded"></div>
Autre
</button>
<div className="border-t border-gray-200 mt-2 pt-2">
<button
onClick={cancelSelection}
className="w-full text-center px-3 py-2 text-red-600 hover:bg-red-50 rounded text-sm"
>
Annuler
</button>
</div>
</div>
)}
{/* Hover tooltip */}
{hoveredLeave && !isMobile && (
<div
className="fixed bg-white rounded-lg shadow-xl border p-4 z-50 min-w-[250px]"
style={{
left: Math.min(mousePosition.x + 10, window.innerWidth - 280),
top: mousePosition.y + 10,
}}
>
{/* Tooltip content - TO BE COMPLETED */}
<div className="text-sm font-semibold">{hoveredLeave.employee.name}</div>
<div className="text-xs text-gray-500 mt-1">{hoveredLeave.leave.type}</div>
</div>
)}
{/* Toasts */}
<div className="fixed top-20 right-4 z-50 space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 py-3 rounded-lg shadow-lg animate-slideInRight ${toast.type === 'success' ? 'bg-green-500 text-white' :
toast.type === 'error' ? 'bg-red-500 text-white' :
'bg-blue-500 text-white'
}`}
>
{toast.message}
</div>
))}
</div>
{/* Leave request modal */}
{showNewRequestModal && detailedCounters && (
<NewLeaveRequestModal
onClose={() => {
setShowNewRequestModal(false);
setPreselectedDates(null);
}}
availableLeaveCounters={{
availableCPN: detailedCounters.cpN?.solde || 0,
totalCPN: detailedCounters.cpN?.acquis || 0,
availableCPN1: detailedCounters.cpN1?.solde || 0,
availableRTTN: detailedCounters.rttN?.solde || 0,
totalRTTN: detailedCounters.rttN?.acquis || 0,
availableRTTN1: 0,
availableABS: 0,
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
availableRTT: detailedCounters.rttN?.solde || 0,
availableRecup: detailedCounters?.recupN?.solde || 0,
availableRecupN: detailedCounters?.recupN?.solde || 0,
}}
accessToken={graphToken}
userId={userId}
userRole={user.role}
userEmail={user.email}
userName={`${user.prenom} ${user.nom}`}
onRequestSubmitted={refreshAllData}
preselectedStartDate={preselectedDates ? formatDateToString(preselectedDates.startDate) : null}
preselectedEndDate={preselectedDates ? formatDateToString(preselectedDates.endDate) : null}
preselectedType={preselectedDates?.type}
/>
)}
<GlobalTutorial userId={userId} userRole={user?.role} />
<style>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
@keyframes slideInRight {
from { transform: translateX(400px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-slideInRight {
animation: slideInRight 0.3s ease-out;
}
`}</style>
</div>
);
};
export default Calendar;