1527 lines
77 KiB
JavaScript
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;
|