Files
GTA/project/src/pages/Calendar.jsx
Ouijdane IMER fbcd80fb6f Affichage des noms des collaborateurs en congé dans le calendrier.
Ajout du champ "Autre" dans le formulaire de demande de congé.
Mise en place de l’export du calendrier aux formats ICS et CSV.
2025-08-06 16:49:31 +02:00

670 lines
30 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import Sidebar from '../components/Sidebar';
import { ChevronLeft, ChevronRight, Plus, X, Menu } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import NewLeaveRequestModal from '../components/NewLeaveRequestModal';
const Calendar = () => {
const { user } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
const [selectedEndDate, setSelectedEndDate] = useState(null);
const [isSelectingRange, setIsSelectingRange] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [contextMenu, setContextMenu] = useState({ show: false, x: 0, y: 0 });
const [preselectedType, setPreselectedType] = useState(null);
const [holidays, setHolidays] = useState([]);
const [isLoadingHolidays, setIsLoadingHolidays] = useState(true);
const [leaveCounters, setLeaveCounters] = useState({
availableCP: 25,
availableRTT: 8,
availableABS: 0
});
const [teamLeaves, setTeamLeaves] = useState([]);
const monthNames = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'];
// Récupération des jours fériés depuis l'API gouvernementale française
const fetchFrenchHolidays = async (year) => {
try {
setIsLoadingHolidays(true);
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();
// Convertir les dates de l'API en objets Date
const holidayDates = Object.keys(data).map(dateStr => {
const [year, month, day] = dateStr.split('-').map(Number);
return {
date: new Date(year, month - 1, day), // month - 1 car les mois JS commencent à 0
name: data[dateStr]
};
});
return holidayDates;
} catch (error) {
console.error('Erreur lors de la récupération des jours fériés:', error);
// Fallback avec quelques jours fériés fixes si l'API échoue
return [
{ date: new Date(year, 0, 1), name: 'Jour de l\'An' },
{ date: new Date(year, 5, 1), name: 'Fête du Travail' },
{ date: new Date(year, 6, 14), name: 'Fête Nationale' },
{ date: new Date(year, 12, 25), name: 'Noël' }
];
} finally {
setIsLoadingHolidays(false);
}
};
// Charger les jours fériés au montage du composant et lors du changement d'année
useEffect(() => {
const loadHolidays = async () => {
const currentYear = currentDate.getFullYear();
const nextYear = currentYear + 1;
const prevYear = currentYear - 1;
// Charger les jours fériés pour l'année précédente, actuelle et suivante
const [prevYearHolidays, currentYearHolidays, nextYearHolidays] = await Promise.all([
fetchFrenchHolidays(prevYear),
fetchFrenchHolidays(currentYear),
fetchFrenchHolidays(nextYear)
]);
// Combiner tous les jours fériés
const allHolidays = [...prevYearHolidays, ...currentYearHolidays, ...nextYearHolidays];
setHolidays(allHolidays);
};
const loadTeamLeaves = async () => {
if (user?.id) {
try {
const response = await fetch(`http://localhost/GTA/project/public/getTeamLeaves.php?user_id=${user.id}`);
const data = await response.json();
if (data.success) {
setTeamLeaves(data.leaves || []);
}
} catch (error) {
console.error('Erreur récupération congés équipe:', error);
}
}
};
loadHolidays();
loadTeamLeaves();
}, [currentDate.getFullYear()]);
// Fermer le menu contextuel quand on clique ailleurs
useEffect(() => {
const handleClickOutside = () => {
setContextMenu({ show: false, x: 0, y: 0 });
};
if (contextMenu.show) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [contextMenu.show]);
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.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; // Adjust for Monday start
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
// Add days of the month (only weekdays)
for (let day = 1; day <= daysInMonth; day++) {
const currentDay = new Date(year, month, day);
const dayOfWeek = currentDay.getDay();
// Only add weekdays (Monday = 1 to Friday = 5)
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
days.push(currentDay);
}
}
return days;
};
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;
});
};
const isToday = (date) => {
if (!date) return false;
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isPastDate = (date) => {
if (!date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
};
const isHoliday = (date) => {
if (!date) return false;
return holidays.some(holiday => holiday.date.toDateString() === date.toDateString());
};
const getHolidayName = (date) => {
if (!date) return null;
const holiday = holidays.find(holiday => holiday.date.toDateString() === date.toDateString());
return holiday ? holiday.name : null;
};
const isSelected = (date) => {
if (!date || !selectedDate) return false;
if (selectedEndDate) {
return date >= selectedDate && date <= selectedEndDate;
}
return date.toDateString() === selectedDate.toDateString();
};
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 handleDateClick = (date) => {
if (!date || isPastDate(date) || isHoliday(date)) return;
if (!selectedDate) {
// Première sélection
setSelectedDate(date);
setSelectedEndDate(null);
setIsSelectingRange(true);
} else if (isSelectingRange && !selectedEndDate) {
// Deuxième sélection pour la plage
if (date >= selectedDate) {
setSelectedEndDate(date);
setIsSelectingRange(false);
// Ouvrir automatiquement le modal avec les dates pré-remplies
setTimeout(() => {
setShowNewRequestModal(true);
}, 100);
} else {
// Si la date est antérieure, recommencer la sélection
setSelectedDate(date);
setSelectedEndDate(null);
}
} else {
// Nouvelle sélection (reset)
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) => {
setPreselectedType(type);
setContextMenu({ show: false, x: 0, y: 0 });
setShowNewRequestModal(true);
};
const resetSelection = () => {
setSelectedDate(null);
setSelectedEndDate(null);
setIsSelectingRange(false);
setPreselectedType(null);
};
const getSelectedDays = () => {
if (!selectedDate) return 0;
if (selectedEndDate) {
return calculateWorkingDays(selectedDate, selectedEndDate);
}
return 1;
};
const getAvailableTypes = () => {
const days = getSelectedDays();
const types = [];
if (leaveCounters.availableCP >= days) {
types.push({ key: 'CP', label: 'Congés payés', color: 'bg-blue-600', available: leaveCounters.availableCP });
}
if (days <= 5 && leaveCounters.availableRTT >= days) {
types.push({ key: 'RTT', label: 'RTT', color: 'bg-green-600', available: leaveCounters.availableRTT });
}
types.push({ key: 'ABS', label: 'Congé maladie', color: 'bg-red-600', available: '∞' });
return types;
};
const hasLeave = (date) => {
if (!date) return false;
// Vérifier les congés de l'équipe
return teamLeaves.some(leave => {
const startDate = new Date(leave.start_date);
const endDate = new Date(leave.end_date);
return date >= startDate && date <= endDate;
});
};
const days = getDaysInMonth(currentDate);
// Export CSV
const exportTeamLeavesToGoogleCSV = (teamLeaves) => {
const csvRows = [
['Subject', 'Start Date', 'Start Time', 'End Date', 'End Time', 'All Day Event', 'Description', 'Location', 'Private'],
...teamLeaves.map(leave => {
// Conversion date YYYY-MM-DD -> MM/DD/YYYY
const formatDate = dateStr => {
const [year, month, day] = dateStr.split('-');
return `${month}/${day}/${year}`;
};
return [
`Congé - ${leave.employee_name}`, // Subject
formatDate(leave.start_date), // Start Date
'', // Start Time (vide car congé toute la journée)
formatDate(leave.end_date), // End Date
'', // End Time
'TRUE', // All Day Event
`Type de congé: ${leave.type}`, // Description
'', // Location vide
'TRUE' // Privé
];
})
];
const csvContent = csvRows.map(row => row.join(',')).join('\n');
// Ajouter BOM UTF-8 pour Excel/Google Sheets
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'conges_equipe_googleagenda.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Export ICS
const exportTeamLeavesToICS = (teamLeaves) => {
const ICS_HEADER =
`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//TonEntreprise//CongesEquipe//FR
CALSCALE:GREGORIAN`;
const ICS_FOOTER = 'END:VCALENDAR';
const events = teamLeaves.map(leave => {
// Format date YYYYMMDD
const startDate = leave.start_date.replace(/-/g, '');
const endDate = leave.end_date.replace(/-/g, '');
return `BEGIN:VEVENT
SUMMARY:Congé - ${leave.employee_name}
DTSTART;VALUE=DATE:${startDate}
DTEND;VALUE=DATE:${endDate}
DESCRIPTION:Type de congé: ${leave.type}
STATUS:CONFIRMED
END:VEVENT`;
}).join('\n');
const icsContent = `${ICS_HEADER}\n${events}\n${ICS_FOOTER}`;
// Blob pour téléchargement
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'conges_equipe.ics';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60 p-4 lg:p-8">
{/* Mobile menu button */}
<div className="lg:hidden mb-4">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg bg-white shadow-sm border border-gray-200"
>
<Menu className="w-6 h-6" />
</button>
</div>
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">Calendrier</h1>
<p className="text-sm lg:text-base text-gray-600">Vue d'ensemble de vos congés</p>
</div>
<div className="flex gap-2 lg:gap-3">
{(selectedDate || selectedEndDate) && (
<button
onClick={resetSelection}
className="bg-gray-600 text-white px-3 lg:px-4 py-2 lg:py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors flex items-center gap-2"
>
<X className="w-5 h-5" />
<span className="hidden lg:inline">Annuler sélection</span>
</button>
)}
<button
onClick={() => setShowNewRequestModal(true)}
className="bg-blue-600 text-white px-3 lg:px-6 py-2 lg:py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">Nouvelle demande</span>
<span className="sm:hidden">Nouveau</span>
</button>
</div>
</div>
{/* Selection 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>
{!isSelectingRange && (
<p className="text-blue-600 text-xs lg:text-sm">
Clic droit pour choisir le type de congé
</p>
)}
</div>
</div>
)}
{/* Calendar */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 lg:p-6">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl lg:text-2xl font-bold text-gray-900">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
{isLoadingHolidays && (
<span className="ml-2 text-xs lg:text-sm text-gray-500">
<div className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span className="ml-2">Chargement des jours fériés...</span>
</span>
)}
</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>
{/* Day Names */}
<div className="grid grid-cols-5 gap-2 mb-2">
{dayNames.map(day => (
<div key={day} className="p-2 lg:p-3 text-center text-xs lg:text-sm font-medium text-gray-500">
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-5 gap-1 lg:gap-2">
{days.map((date, index) => (
<div
key={index}
className={`
min-h-[60px] lg:min-h-[80px] p-1 lg:p-2 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-red-100 text-red-800 cursor-not-allowed border border-red-200' :
isToday(date) ? 'bg-blue-100 text-blue-800 font-semibold' :
isSelected(date) ? 'bg-blue-600 text-white' :
hasLeave(date) ? 'bg-green-100 text-green-800' :
'hover:bg-gray-50'
}
${isSelectingRange && selectedDate && !selectedEndDate && date && date > selectedDate && !isPastDate(date) && !isHoliday(date) ? 'bg-blue-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">
{/* Date number */}
<span className="text-xs lg:text-sm">{date.getDate()}</span>
{isHoliday(date) && getHolidayName(date) && (
<div className="text-[10px] text-red-700 font-medium mt-1 text-center leading-tight break-words">
{getHolidayName(date)}
</div>
)}
{/* Leave info: names of employees with leave */}
{hasLeave(date) && (
<div className="mt-1 flex flex-col items-center space-y-0.5 text-[10px] lg:text-xs text-green-700 text-center leading-tight">
{teamLeaves
.filter(leave => {
const start = new Date(leave.start_date);
const end = new Date(leave.end_date);
return date >= start && date <= end;
})
.map((leave, i) => (
<div
key={i}
title={`${leave.employee_name} - ${leave.type}`}
className="flex items-center gap-1"
>
{/* Optional colored dot for leave type */}
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: leave.color || '#3B82F6' }}
></span>
<span className="text-[10px] lg:text-xs text-green-800 leading-tight break-words text-center">
{leave.employee_name}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
{/* Legend */}
<div className="flex items-center gap-3 lg:gap-6 mt-6 pt-6 border-t border-gray-100 flex-wrap text-xs lg:text-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-blue-100 rounded"></div>
<span className="text-gray-600">Aujourd'hui</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-green-100 rounded"></div>
<span className="text-gray-600 hidden lg:inline">Congés approuvés</span>
<span className="text-gray-600 lg:hidden">Approuvés</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-yellow-100 rounded"></div>
<span className="text-gray-600 hidden lg:inline">Congés en attente</span>
<span className="text-gray-600 lg:hidden">En attente</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-red-100 border border-red-200 rounded relative">
<div className="absolute top-0 right-0 w-1.5 h-1.5 bg-red-600 rounded-full"></div>
</div>
<span className="text-gray-600 hidden lg:inline">Jours fériés</span>
<span className="text-gray-600 lg:hidden">Fériés</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-gray-200 rounded opacity-60"></div>
<span className="text-gray-600 hidden lg:inline">Jours passés</span>
<span className="text-gray-600 lg:hidden">Passés</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 lg:w-3 lg:h-3 bg-blue-600 rounded"></div>
<span className="text-gray-600">Sélection</span>
</div>
</div>
</div>
{/* Context Menu */}
{contextMenu.show && (
<div
className="fixed bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50 min-w-[200px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<div className="px-4 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>
{getAvailableTypes().map(type => (
<button
key={type.key}
onClick={() => handleTypeSelection(type.key)}
className="w-full px-4 py-2 text-left hover:bg-gray-50 flex items-center justify-between text-sm"
>
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${type.color}`}></div>
<span className="text-gray-900">{type.label}</span>
</div>
<span className="text-xs text-gray-500">
{type.available} disponible{type.available !== 1 && type.available !== '∞' ? 's' : ''}
</span>
</button>
))}
<div className="border-t border-gray-100 mt-1">
<button
onClick={() => {
setContextMenu({ show: false, x: 0, y: 0 });
setShowNewRequestModal(true);
}}
className="w-full px-4 py-2 text-left hover:bg-gray-50 text-xs lg:text-sm text-gray-600"
>
Formulaire complet...
</button>
</div>
</div>
)}
{/* button csv */}
<div className="p-4">
<div className="flex justify-end gap-2 mb-4">
<button onClick={() => exportTeamLeavesToGoogleCSV(teamLeaves)} className="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700">
Export CSV
</button>
<button onClick={() => exportTeamLeavesToICS(teamLeaves)} className="bg-green-600 text-white px-3 py-2 rounded hover:bg-green-700">
Export ICS
</button>
</div>
{/* Ton rendu de calendrier ici */}
</div>
{/* Modal nouvelle demande */}
{showNewRequestModal && (
<NewLeaveRequestModal
onClose={() => {
setShowNewRequestModal(false);
setPreselectedType(null);
}}
availableLeaveCounters={leaveCounters}
userId={user?.id}
onRequestSubmitted={() => {
resetSelection();
}}
preselectedStartDate={selectedDate}
preselectedEndDate={selectedEndDate}
preselectedType={preselectedType}
/>
)}
</div>
</div>
);
};
export default Calendar;