1667 lines
86 KiB
JavaScript
1667 lines
86 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";
|
||
|
||
const Calendar = () => {
|
||
const { user } = useAuth();
|
||
const role = user?.role?.toLowerCase();
|
||
const userId = user?.id || user?.CollaborateurADId || user?.ID;
|
||
|
||
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([]);
|
||
|
||
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'];
|
||
const dayNamesMobile = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'];
|
||
|
||
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(`http://localhost:3000/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 = `http://localhost:3000/getTeamLeaves?user_id=${userId}&role=${user.role}`;
|
||
|
||
// ⭐ PRESIDENT/ADMIN/RH : Envoyer le campus
|
||
if (role === 'president' || role === 'rh' || role === 'admin') {
|
||
if (selectedCampus !== 'all') {
|
||
url += `&selectedCampus=${encodeURIComponent(selectedCampus)}`;
|
||
}
|
||
if (selectedSociete !== 'all') {
|
||
url += `&selectedSociete=${encodeURIComponent(selectedSociete)}`;
|
||
}
|
||
if (selectedService !== 'all') {
|
||
url += `&selectedService=${encodeURIComponent(selectedService)}`;
|
||
}
|
||
}
|
||
|
||
// ⭐ DIRECTEUR DE CAMPUS : Envoyer société et service
|
||
if (role === 'directeur de campus' || role === 'directrice de campus') {
|
||
if (selectedSociete !== 'all') {
|
||
url += `&selectedSociete=${encodeURIComponent(selectedSociete)}`;
|
||
}
|
||
if (selectedService !== 'all') {
|
||
url += `&selectedService=${encodeURIComponent(selectedService)}`;
|
||
}
|
||
}
|
||
|
||
console.log("📡 Appel API:", url);
|
||
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
console.log("📡 Réponse API :", data);
|
||
|
||
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]);
|
||
|
||
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);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
refreshAllData();
|
||
}, [refreshAllData]);
|
||
|
||
// ⭐ Recharger quand les filtres changent
|
||
useEffect(() => {
|
||
if ((role === 'president' || role === 'rh' || role === 'admin') && selectedCampus) {
|
||
loadTeamLeaves();
|
||
}
|
||
}, [selectedCampus, role]);
|
||
|
||
// ⭐ Recharger pour Directeur de Campus quand société OU service changent
|
||
// Recharger pour Directeur de Campus quand société OU service changent
|
||
useEffect(() => {
|
||
if (role === 'directeur de campus' || role === 'directrice de campus') {
|
||
console.log("🔄 Rechargement Directeur Campus:", {
|
||
societe: selectedSociete,
|
||
service: selectedService,
|
||
role: role
|
||
});
|
||
loadTeamLeaves();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [selectedSociete, 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();
|
||
|
||
// ⭐ MODIFIÉ : Inclure du lundi au samedi (1 à 6)
|
||
if (dayOfWeek >= 1 && dayOfWeek <= 6) {
|
||
days.push(currentDay);
|
||
}
|
||
}
|
||
|
||
return days;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
// ⭐ FILTRAGE DE BASE SELON LE RÔLE ET LES FILTRES CAMPUS/SOCIÉTÉ/SERVICE
|
||
const getBaseFilteredLeaves = () => {
|
||
// Pour directeur de campus - le backend a déjà tout filtré
|
||
if (role === 'directeur de campus' || role === 'directrice de campus') {
|
||
return teamLeaves;
|
||
}
|
||
|
||
// Pour collaborateur, validateur, apprenti - pas de filtres
|
||
if (['collaborateur', 'collaboratrice','validateur', 'apprenti'].includes(role)) {
|
||
return teamLeaves;
|
||
}
|
||
|
||
// Pour president, admin, rh - appliquer les filtres frontend
|
||
return teamLeaves.filter(leave => {
|
||
if (selectedCampus !== 'all' && leave.campusnom !== selectedCampus) return false;
|
||
if (selectedSociete !== 'all' && leave.societenom !== selectedSociete) return false;
|
||
if (selectedService !== 'all' && leave.servicenom !== selectedService) return false;
|
||
return true;
|
||
});
|
||
};
|
||
|
||
// ⭐ OBTENIR TOUS LES EMPLOYÉS DISPONIBLES APRÈS FILTRAGE DE BASE
|
||
// ⭐ OBTENIR TOUS LES EMPLOYÉS DISPONIBLES - déjà filtrés par le backend
|
||
const getAllEmployees = () => {
|
||
if (!filters.employees || filters.employees.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
// Pour directeur de campus - le backend a déjà tout filtré
|
||
if (role === 'directeur de campus' || role === 'directrice de campus') {
|
||
const employeeList = filters.employees.map(emp => ({
|
||
name: typeof emp === 'string' ? emp : emp.name,
|
||
campus: emp.campus || '',
|
||
societe: emp.societe || '',
|
||
service: emp.service || ''
|
||
}));
|
||
|
||
// Dédupliquer par nom
|
||
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.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
// Pour president/admin/rh - appliquer les filtres frontend
|
||
const filteredEmployees = filters.employees.filter(emp => {
|
||
if (role === 'president' || role === 'rh' || role === 'admin') {
|
||
if (selectedCampus !== 'all' && emp.campus !== selectedCampus) return false;
|
||
if (selectedSociete !== 'all' && emp.societe !== selectedSociete) return false;
|
||
if (selectedService !== 'all' && emp.service !== selectedService) return false;
|
||
return true;
|
||
}
|
||
|
||
// Pour collaborateur/validateur/apprenti - pas de filtre
|
||
return true;
|
||
}).map(emp => ({
|
||
name: typeof emp === 'string' ? emp : emp.name,
|
||
campus: emp.campus || '',
|
||
societe: emp.societe || '',
|
||
service: emp.service || ''
|
||
}));
|
||
|
||
// Dédupliquer par nom
|
||
const uniqueEmployees = [];
|
||
const seenNames = new Set();
|
||
|
||
for (const emp of filteredEmployees) {
|
||
if (!seenNames.has(emp.name)) {
|
||
seenNames.add(emp.name);
|
||
uniqueEmployees.push(emp);
|
||
}
|
||
}
|
||
|
||
return uniqueEmployees.sort((a, b) => a.name.localeCompare(b.name));
|
||
};
|
||
|
||
// ⭐ OBTENIR LES EMPLOYÉS À AFFICHER DANS LE TABLEAU
|
||
const getDisplayedEmployees = () => {
|
||
const allEmployees = getAllEmployees();
|
||
|
||
let filteredEmployees = allEmployees;
|
||
|
||
if (selectedEmployees.length > 0) {
|
||
filteredEmployees = allEmployees.filter(emp => selectedEmployees.includes(emp.name));
|
||
}
|
||
|
||
// ⭐ TRI AUTOMATIQUE pour President/RH/Admin/Directeur de Campus
|
||
// quand "Tous les campus" ET "Toutes les sociétés" ET "Tous les services"
|
||
const shouldAutoSort = (
|
||
['president', 'rh', 'admin'].includes(role) &&
|
||
selectedCampus === 'all' &&
|
||
selectedSociete === 'all' &&
|
||
selectedService === 'all'
|
||
) || (
|
||
(role === 'directeur de campus' || role === 'directrice de campus') &&
|
||
selectedSociete === 'all' &&
|
||
selectedService === 'all'
|
||
);
|
||
|
||
if (shouldAutoSort) {
|
||
console.log("🔄 Tri automatique par société → service → nom");
|
||
|
||
return filteredEmployees.sort((a, b) => {
|
||
// 1. Tri par société
|
||
const societeCompare = (a.societe || '').localeCompare(b.societe || '');
|
||
if (societeCompare !== 0) return societeCompare;
|
||
|
||
// 2. Tri par service
|
||
const serviceCompare = (a.service || '').localeCompare(b.service || '');
|
||
if (serviceCompare !== 0) return serviceCompare;
|
||
|
||
// 3. Tri par nom
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
}
|
||
|
||
// ⭐ Sinon, tri simple par nom
|
||
return filteredEmployees.sort((a, b) => a.name.localeCompare(b.name));
|
||
};
|
||
// ⭐ OBTENIR LES CONGÉS À AFFICHER
|
||
const getDisplayedLeaves = () => {
|
||
const baseFiltered = getBaseFilteredLeaves();
|
||
const displayedEmployees = getDisplayedEmployees();
|
||
const displayedNames = displayedEmployees.map(emp => emp.name);
|
||
|
||
return baseFiltered.filter(leave => displayedNames.includes(leave.employeename));
|
||
};
|
||
|
||
// ⭐ USEMEMO POUR ÉVITER LES RECALCULS INUTILES
|
||
|
||
const allEmployeesData = useMemo(() => {
|
||
return getAllEmployees();
|
||
}, (role === 'directeur de campus' || role === 'directrice de campus')
|
||
? [filters.employees] // Directeur: seulement filters.employees (backend filtre)
|
||
: [teamLeaves, filters, selectedCampus, selectedSociete, selectedService, role] // Autres: tous les filtres
|
||
);
|
||
|
||
// ⭐ NETTOYER LA SÉLECTION SI DES EMPLOYÉS NE SONT PLUS DISPONIBLES
|
||
|
||
useEffect(() => {
|
||
const availableNames = allEmployeesData.map(emp => emp.name);
|
||
setSelectedEmployees(prev => {
|
||
const filtered = prev.filter(name => availableNames.includes(name));
|
||
// ⭐ Ne retourner que si vraiment différent pour éviter boucles
|
||
if (filtered.length !== prev.length) {
|
||
console.log("🧹 Nettoyage employés:", {
|
||
avant: prev.length,
|
||
après: filtered.length
|
||
});
|
||
return filtered;
|
||
}
|
||
return prev;
|
||
});
|
||
}, [allEmployeesData]);
|
||
|
||
|
||
// ⭐ LOG DE DÉBOGAGE
|
||
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 '';
|
||
|
||
const type = leave.type?.toLowerCase();
|
||
const status = leave.statut?.toLowerCase();
|
||
|
||
if (status === 'en attente' || status === 'pending' || status === 'en attente de validation') {
|
||
return 'bg-orange-400';
|
||
}
|
||
|
||
if (type === 'formation' || type === 'training') {
|
||
return 'bg-blue-400';
|
||
}
|
||
|
||
return 'bg-green-400';
|
||
};
|
||
|
||
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;
|
||
|
||
const saturday = isSaturday(date);
|
||
|
||
// ⭐ Si c'est un samedi et qu'on n'a pas encore de sélection en cours
|
||
if (saturday && !selectedDate) {
|
||
// Permettre de sélectionner un samedi seul
|
||
setSelectedDate(date);
|
||
setSelectedEndDate(null);
|
||
setIsSelectingRange(true);
|
||
showToast('📅 Samedi sélectionné - Cliquez sur une autre date ou validez pour une récupération', 'info');
|
||
return;
|
||
}
|
||
|
||
// ⭐ Si on clique sur un samedi alors qu'on a déjà une sélection en cours
|
||
if (saturday && selectedDate && isSelectingRange) {
|
||
// Vérifier que la plage contient bien un samedi
|
||
if (date >= selectedDate) {
|
||
setSelectedEndDate(date);
|
||
setIsSelectingRange(false);
|
||
|
||
// Ouvrir directement le modal avec type "Récup" pré-sélectionné
|
||
setPreselectedDates({
|
||
startDate: selectedDate,
|
||
endDate: date,
|
||
type: 'Récup'
|
||
});
|
||
setShowNewRequestModal(true);
|
||
setSelectedDate(null);
|
||
setSelectedEndDate(null);
|
||
} else {
|
||
showToast('⚠️ La date de fin doit être après la date de début', 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ⭐ Logique normale pour les jours de semaine
|
||
if (!selectedDate) {
|
||
setSelectedDate(date);
|
||
setSelectedEndDate(null);
|
||
setIsSelectingRange(true);
|
||
} else if (isSelectingRange && !selectedEndDate) {
|
||
if (date >= selectedDate) {
|
||
setSelectedEndDate(date);
|
||
setIsSelectingRange(false);
|
||
|
||
const includesSaturday = checkIfRangeIncludesSaturday(selectedDate, date);
|
||
if (includesSaturday) {
|
||
// Si la plage inclut un samedi, forcer le type Récup
|
||
setPreselectedDates({
|
||
startDate: selectedDate,
|
||
endDate: date,
|
||
type: 'Récup'
|
||
});
|
||
setShowNewRequestModal(true);
|
||
setSelectedDate(null);
|
||
setSelectedEndDate(null);
|
||
} else {
|
||
// Sinon, afficher le menu contextuel
|
||
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);
|
||
|
||
if (includesSaturday && type !== 'Récup') {
|
||
showToast('⚠️ Les samedis ne peuvent être sélectionnés que pour les récupérations', 'error');
|
||
setContextMenu({ show: false, x: 0, y: 0 });
|
||
return;
|
||
}
|
||
|
||
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'].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 clearAllFilters = () => {
|
||
setEmployeeFilter("all");
|
||
setSelectedCampus("all");
|
||
setSelectedSociete("all");
|
||
setSelectedService("all");
|
||
setSelectedEmployees([]);
|
||
};
|
||
const renderLeaveCell = (leave) => {
|
||
if (!leave) return null;
|
||
|
||
const color = getLeaveColor(leave);
|
||
|
||
// 🔹 CORRECTION : Parser correctement les détails
|
||
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 = [];
|
||
}
|
||
|
||
// Vérifier si c'est une demi-journée
|
||
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">
|
||
{hasMatin && <div className={`absolute top-0 left-0 right-0 h-3 ${color}`}></div>}
|
||
{hasApresMidi && <div className={`absolute bottom-0 left-0 right-0 h-3 ${color}`}></div>}
|
||
{!hasMatin && !hasApresMidi && <div className={`h-full w-full ${color}`}></div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Affichage normal pour journée entière
|
||
return <div className={`h-6 w-6 mx-auto ${color} rounded cursor-pointer`}></div>;
|
||
};
|
||
|
||
|
||
return (
|
||
<div className="flex h-screen bg-gray-50" onMouseMove={handleMouseMove}>
|
||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||
|
||
<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>
|
||
|
||
<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">
|
||
<div className="hidden lg:flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-4">
|
||
<h1 className="text-xl font-semibold text-gray-800">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 className="flex items-center gap-4">
|
||
<div className="bg-gray-100 px-4 py-1.5 rounded">
|
||
<span className="text-sm text-gray-600">PTO: </span>
|
||
<span className="text-sm font-semibold">{totalPTO.toFixed(1)}j</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lg:hidden mb-4 flex justify-center">
|
||
<div className="bg-blue-50 border border-blue-200 px-4 py-2 rounded-lg">
|
||
<span className="text-sm text-gray-600">PTO: </span>
|
||
<span className="text-sm font-bold text-blue-600">{totalPTO.toFixed(1)}j</span>
|
||
</div>
|
||
</div>
|
||
|
||
{isSelectingRange && (
|
||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
||
<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>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
|
||
<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"
|
||
>
|
||
<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={clearAllFilters}
|
||
className="text-sm text-blue-600 hover:text-blue-700"
|
||
>
|
||
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"
|
||
>
|
||
<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">
|
||
|
||
{/* FILTRES POUR PRESIDENT/ADMIN/RH : Campus + Societe + Service */}
|
||
{canViewAllFilters && (
|
||
<>
|
||
{filters.campus && filters.campus.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Campus
|
||
</label>
|
||
<select
|
||
value={selectedCampus}
|
||
onChange={(e) => {
|
||
setSelectedCampus(e.target.value);
|
||
// Réinitialiser les filtres dépendants
|
||
setSelectedSociete('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">Tous les campus</option>
|
||
{filters.campus?.map((campus, index) => (
|
||
<option key={index} value={campus}>
|
||
{campus}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{filters.societes && filters.societes.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Société
|
||
</label>
|
||
<select
|
||
value={selectedSociete}
|
||
onChange={(e) => {
|
||
setSelectedSociete(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">Toutes les sociétés</option>
|
||
{filters.societes?.map((societe, index) => (
|
||
<option key={index} value={societe}>
|
||
{societe}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{filters.services && filters.services.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Service
|
||
</label>
|
||
<select
|
||
value={selectedService}
|
||
onChange={(e) => {
|
||
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>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
|
||
{/* FILTRES POUR DIRECTEUR DE CAMPUS - Société et Service uniquement */}
|
||
{(role === 'directeur de campus' || role === 'directrice de campus') && filters.societes && filters.societes.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Société
|
||
</label>
|
||
<select
|
||
value={selectedSociete}
|
||
onChange={(e) => {
|
||
console.log("🔄 Changement société:", e.target.value);
|
||
setSelectedSociete(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">Toutes les sociétés</option>
|
||
{filters.societes?.map((soc, index) => (
|
||
<option key={index} value={soc}>
|
||
{soc}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{(role === 'directeur de campus' || role === 'directrice de campus') && filters.services && filters.services.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Service
|
||
</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((srv, index) => (
|
||
<option key={index} value={srv}>
|
||
{srv}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Section Collaborateurs - pour tous les rôles */}
|
||
<div className="col-span-full">
|
||
<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}`}
|
||
{canViewAllFilters && employee.campus && ` • ${employee.campus}`}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!isMobile && (
|
||
<div className="bg-white rounded-lg border overflow-hidden shadow-sm">
|
||
<div className="overflow-x-auto">
|
||
<div className="inline-block min-w-full">
|
||
<div className="flex items-center justify-center py-4 px-4 border-b bg-gray-50">
|
||
<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>
|
||
|
||
<table className="w-full border-collapse">
|
||
<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 = dayNames[date.getDay() - 1];
|
||
const inRange = isSelected(date);
|
||
const past = isPastDate(date);
|
||
const holiday = isHoliday(date);
|
||
const saturday = isSaturday(date);
|
||
|
||
return (
|
||
<th
|
||
key={index}
|
||
className={`border-r px-2 py-2 text-center min-w-[40px] ${holiday ? 'bg-gray-600 text-white' :
|
||
saturday ? 'bg-purple-100' :
|
||
inRange ? 'bg-blue-100' :
|
||
past ? 'bg-gray-100' : 'bg-gray-50'
|
||
}`}
|
||
title={holiday ? getHolidayName(date) : saturday ? 'Samedi - Récupération uniquement' : ''}
|
||
>
|
||
<div className={`text-xs font-normal ${holiday ? 'text-white' :
|
||
saturday ? 'text-purple-700' :
|
||
past ? 'text-gray-400' : 'text-gray-600'
|
||
}`}>{dayName}</div>
|
||
<div className={`text-sm font-medium ${holiday ? 'text-white' :
|
||
saturday ? 'text-purple-700' :
|
||
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 isToday = date.toDateString() === new Date().toDateString();
|
||
|
||
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' : ''} ${saturday ? 'bg-purple-50' : ''
|
||
}`}
|
||
onClick={() => {
|
||
// ⭐ Permettre le clic sur les samedis aussi (retirer le check !saturday)
|
||
if (!leave && employee.name === `${user.prenom} ${user.nom}` && !past && !holiday) {
|
||
handleDateClick(date);
|
||
}
|
||
}}
|
||
onContextMenu={(e) => handleContextMenu(e, date)}
|
||
onMouseEnter={() => !isMobile && leave && setHoveredLeave({ employee, leave, date })}
|
||
onMouseLeave={() => setHoveredLeave(null)}
|
||
>
|
||
{/* ⭐ REMPLACER CETTE SECTION */}
|
||
{holiday ? (
|
||
<div className="h-6 w-6 mx-auto bg-gray-700 rounded" title={getHolidayName(date)}></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'
|
||
: saturday
|
||
? 'bg-purple-200 hover:bg-purple-300'
|
||
: '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>
|
||
|
||
<div className="flex items-center gap-3 lg:gap-6 p-4 border-t border-gray-100 flex-wrap text-xs lg:text-sm">
|
||
<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-purple-200 rounded"></div>
|
||
<span className="text-gray-600">Samedi (Récup)</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 bg-blue-500 rounded"></div>
|
||
<span className="text-gray-600">Sélection</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isMobile && (
|
||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-xl font-bold text-gray-900">
|
||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||
</h2>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => navigateMonth('prev')}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<ChevronLeft className="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
onClick={() => navigateMonth('next')}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<ChevronRight className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-5 gap-2 mb-2">
|
||
{dayNamesMobile.map(day => (
|
||
<div key={day} className="p-2 text-center text-xs font-medium text-gray-500">
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-5 gap-1">
|
||
{days.map((date, index) => (
|
||
<div
|
||
key={index}
|
||
className={`
|
||
min-h-[60px] p-1 text-center cursor-pointer rounded-lg transition-colors relative flex flex-col
|
||
${!date ? '' :
|
||
isPastDate(date) ? 'bg-gray-200 text-gray-500 cursor-not-allowed opacity-60' :
|
||
isHoliday(date) ? 'bg-gray-700 text-white cursor-not-allowed border border-gray-800' :
|
||
isSelected(date) ? 'bg-blue-600 text-white' :
|
||
hasLeave(date) ? 'bg-green-100' : 'hover:bg-gray-50'
|
||
}
|
||
`}
|
||
onClick={() => handleDateClick(date)}
|
||
onContextMenu={(e) => handleContextMenu(e, date)}
|
||
title={isHoliday(date) ? getHolidayName(date) : ''}
|
||
>
|
||
{date && (
|
||
<div className="flex flex-col items-center justify-between h-full">
|
||
<span className="text-xs">{date.getDate()}</span>
|
||
|
||
{isHoliday(date) && getHolidayName(date) && (
|
||
<div className="text-[10px] text-white font-medium mt-1 text-center leading-tight break-words">
|
||
{getHolidayName(date)}
|
||
</div>
|
||
)}
|
||
|
||
{hasLeave(date) && (
|
||
<div className="mt-1 flex flex-col items-center space-y-0.5 text-[10px] text-center leading-tight">
|
||
{getDisplayedLeaves()
|
||
.filter(leave => {
|
||
const start = parseLocalDate(leave.startdate);
|
||
const end = parseLocalDate(leave.enddate);
|
||
return date >= start && date <= end;
|
||
})
|
||
.map((leave, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex flex-col items-center gap-1"
|
||
>
|
||
<span className={`text-[10px] leading-tight break-words text-center ${leave.type === 'Formation' ? 'text-blue-800' :
|
||
leave.statut === 'En attente' ? 'text-orange-800' :
|
||
'text-green-800'
|
||
}`}>
|
||
{leave.employeename}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-gray-100 flex-wrap text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-green-100 rounded"></div>
|
||
<span className="text-gray-600">Validé</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-orange-100 rounded"></div>
|
||
<span className="text-gray-600">En attente</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-blue-100 rounded"></div>
|
||
<span className="text-gray-600">Formation</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-gray-700 rounded"></div>
|
||
<span className="text-gray-600">Férié</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-blue-600 rounded"></div>
|
||
<span className="text-gray-600">Sélection</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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('Récup')}
|
||
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-300 rounded"></div>
|
||
Récupération (samedi)
|
||
</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>
|
||
)}
|
||
|
||
{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,
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{/* Emoji selon le type de journée */}
|
||
<div className="text-2xl">
|
||
{(() => {
|
||
const details = typeof hoveredLeave.leave.details_conges === 'string'
|
||
? JSON.parse(hoveredLeave.leave.details_conges)
|
||
: hoveredLeave.leave.details_conges;
|
||
|
||
const isPeriodePartielle = details?.some(
|
||
d => d.periode === 'Matin' || d.periode === 'Après-midi'
|
||
);
|
||
|
||
return isPeriodePartielle ? '🌗' : '📅';
|
||
})()}
|
||
</div>
|
||
|
||
<div className="flex-1">
|
||
<div className="font-semibold text-sm mb-1">
|
||
{hoveredLeave.employee.name}
|
||
</div>
|
||
|
||
{/* Afficher les types avec leurs périodes */}
|
||
<div className="space-y-1 mb-2">
|
||
{(() => {
|
||
const details = typeof hoveredLeave.leave.details_conges === 'string'
|
||
? JSON.parse(hoveredLeave.leave.details_conges)
|
||
: hoveredLeave.leave.details_conges;
|
||
|
||
if (details && Array.isArray(details)) {
|
||
return details.map((detail, idx) => (
|
||
<div key={idx} className="text-xs">
|
||
<span className="font-medium">{detail.type}</span>
|
||
{detail.periode !== 'Journée entière' && (
|
||
<span className="text-gray-600 ml-1">
|
||
({detail.periode === 'Matin' ? '🌅 Matin' : '☀️ Après-midi'})
|
||
</span>
|
||
)}
|
||
<span className="text-gray-500 ml-1">
|
||
- {detail.jours}j
|
||
</span>
|
||
</div>
|
||
));
|
||
} else {
|
||
// Fallback pour ancien format
|
||
return (
|
||
<div className="text-xs">
|
||
<span className="font-medium">{hoveredLeave.leave.type || 'Congé'}</span>
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
</div>
|
||
|
||
<div className={`text-xs px-2 py-0.5 rounded inline-block ${hoveredLeave.leave.statut === 'Validée' ? 'bg-green-100 text-green-700' :
|
||
hoveredLeave.leave.statut === 'En attente' ? 'bg-yellow-100 text-yellow-700' :
|
||
'bg-red-100 text-red-700'
|
||
}`}>
|
||
{hoveredLeave.leave.statut}
|
||
</div>
|
||
|
||
<div className="text-xs text-gray-500 mt-2">
|
||
Du {parseLocalDate(hoveredLeave.leave.startdate)?.toLocaleDateString('fr-FR')}
|
||
au {parseLocalDate(hoveredLeave.leave.enddate)?.toLocaleDateString('fr-FR')}
|
||
</div>
|
||
|
||
{hoveredLeave.leave.nombrejoursouvres && (
|
||
<div className="text-xs text-gray-500">
|
||
Durée totale: <span className="font-medium">
|
||
{hoveredLeave.leave.nombrejoursouvres} jour(s)
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{hoveredLeave && isMobile && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end" onClick={() => setHoveredLeave(null)}>
|
||
<div className="bg-white rounded-t-2xl w-full p-6 animate-slide-up" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h3 className="font-semibold text-lg">{hoveredLeave.employee.name}</h3>
|
||
<p className="text-sm text-gray-600">{hoveredLeave.leave.type || 'Congé'}</p>
|
||
</div>
|
||
<button onClick={() => setHoveredLeave(null)} className="text-gray-400">
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={() => setHoveredLeave(null)}
|
||
className="w-full mt-6 py-3 bg-blue-600 text-white rounded-lg font-medium"
|
||
>
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
{showNewRequestModal && detailedCounters && (
|
||
<NewLeaveRequestModal
|
||
onClose={() => {
|
||
setShowNewRequestModal(false);
|
||
setPreselectedDates(null);
|
||
}}
|
||
availableLeaveCounters={{
|
||
availableCP_N: detailedCounters.cpN?.solde || 0,
|
||
totalCP_N: detailedCounters.cpN?.acquis || 0,
|
||
availableCP_N1: detailedCounters.cpN1?.solde || 0,
|
||
availableRTT_N: detailedCounters.rttN?.solde || 0,
|
||
totalRTT_N: detailedCounters.rttN?.acquis || 0,
|
||
availableRTT_N1: 0,
|
||
availableABS: 0,
|
||
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
|
||
availableRTT: detailedCounters.rttN?.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}
|
||
/>
|
||
)}
|
||
|
||
<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;
|