1023 lines
46 KiB
JavaScript
1023 lines
46 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import Sidebar from '../components/Sidebar';
|
|
import {
|
|
Calendar, Clock, Check, X, Save, Lock, Unlock,
|
|
Download, Menu, AlertCircle, ChevronLeft, ChevronRight,
|
|
FileText, TrendingUp, RefreshCw, Info
|
|
} from 'lucide-react';
|
|
|
|
const CompteRenduActivites = () => {
|
|
const { user } = useAuth();
|
|
const userId = user?.id || user?.CollaborateurADId || user?.ID;
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [joursActifs, setJoursActifs] = useState([]);
|
|
const [statsAnnuelles, setStatsAnnuelles] = useState(null);
|
|
const [mensuelData, setMensuelData] = useState(null);
|
|
const [congesData, setCongesData] = useState([]);
|
|
const [holidays, setHolidays] = useState([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [showSaisieModal, setShowSaisieModal] = useState(false);
|
|
const [selectedJour, setSelectedJour] = useState(null);
|
|
const [showSaisieMasse, setShowSaisieMasse] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [infoMessage, setInfoMessage] = useState(null);
|
|
|
|
// Vérifier l'accès : forfait jour, directeur campus, ou RH
|
|
const hasAccess = () => {
|
|
const userRole = user?.role;
|
|
const typeContrat = user?.TypeContrat || user?.typeContrat;
|
|
|
|
return (
|
|
typeContrat === 'forfait-jour' ||
|
|
typeContrat === 'forfait_jour' ||
|
|
userRole === 'Directeur Campus' ||
|
|
userRole === 'Directrice Campus' ||
|
|
userRole === 'RH' ||
|
|
userRole === 'Admin'
|
|
);
|
|
};
|
|
|
|
const isRH = user?.role === 'RH' || user?.role === 'Admin';
|
|
const isDirecteurCampus = user?.role === 'Directeur Campus' || user?.role === 'Directrice Campus';
|
|
|
|
const annee = currentDate.getFullYear();
|
|
const mois = currentDate.getMonth() + 1;
|
|
|
|
// Charger les jours fériés français
|
|
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;
|
|
};
|
|
|
|
// Charger les données du mois
|
|
const loadCompteRendu = useCallback(async () => {
|
|
if (!userId || !hasAccess()) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch(`/getCompteRenduActivites?user_id=${userId}&annee=${annee}&mois=${mois}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setJoursActifs(data.jours || []);
|
|
setMensuelData(data.mensuel);
|
|
console.log('📅 Jours chargés:', data.jours?.length || 0, 'jours');
|
|
console.log('📊 Détail des jours:', data.jours);
|
|
}
|
|
|
|
const congesResponse = await fetch(`/getTeamLeaves?user_id=${userId}&role=${user.role}`);
|
|
const congesData = await congesResponse.json();
|
|
|
|
if (congesData.success) {
|
|
setCongesData(congesData.leaves || []);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Erreur chargement compte-rendu:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [userId, annee, mois, user?.role]);
|
|
|
|
// Charger les stats annuelles
|
|
const loadStatsAnnuelles = useCallback(async () => {
|
|
if (!userId || !hasAccess()) return;
|
|
|
|
try {
|
|
const response = await fetch(`/getStatsAnnuelles?user_id=${userId}&annee=${annee}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setStatsAnnuelles(data.stats);
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur stats annuelles:', error);
|
|
}
|
|
}, [userId, annee]);
|
|
|
|
useEffect(() => {
|
|
loadCompteRendu();
|
|
loadStatsAnnuelles();
|
|
}, [loadCompteRendu, loadStatsAnnuelles]);
|
|
|
|
// Vérifier si le mois est autorisé (mois en cours + mois précédent)
|
|
const isMoisAutorise = () => {
|
|
const today = new Date();
|
|
const currentYear = today.getFullYear();
|
|
const currentMonth = today.getMonth() + 1;
|
|
|
|
const selectedYear = currentDate.getFullYear();
|
|
const selectedMonth = currentDate.getMonth() + 1;
|
|
|
|
// RH peut tout voir
|
|
if (isRH) return true;
|
|
|
|
// Mois en cours autorisé
|
|
if (selectedYear === currentYear && selectedMonth === currentMonth) return true;
|
|
|
|
// Mois précédent autorisé
|
|
const previousMonth = currentMonth === 1 ? 12 : currentMonth - 1;
|
|
const previousYear = currentMonth === 1 ? currentYear - 1 : currentYear;
|
|
|
|
return selectedYear === previousYear && selectedMonth === previousMonth;
|
|
};
|
|
|
|
// Générer les jours du mois (lundi-samedi) avec décalage correct
|
|
const getDaysInMonth = () => {
|
|
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();
|
|
|
|
// Jour de la semaine du 1er (0=dimanche, 1=lundi, ..., 6=samedi)
|
|
let firstDayOfWeek = firstDay.getDay();
|
|
|
|
// Convertir pour que lundi = 0, mardi = 1, ..., samedi = 5, dimanche = 6
|
|
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
|
|
|
const days = [];
|
|
|
|
// Ajouter des cases vides pour le décalage initial
|
|
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
days.push(null);
|
|
}
|
|
|
|
// Ajouter tous les jours du mois (lundi-samedi uniquement)
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const currentDay = new Date(year, month, day);
|
|
const dayOfWeek = currentDay.getDay();
|
|
|
|
// Exclure les dimanches (0)
|
|
if (dayOfWeek !== 0) {
|
|
days.push(currentDay);
|
|
}
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const getJourData = (date) => {
|
|
const dateStr = formatDateToString(date);
|
|
const found = joursActifs.find(j => {
|
|
// Normaliser la date de la BDD (peut être un objet Date ou une string)
|
|
let jourDateStr = j.JourDate;
|
|
if (j.JourDate instanceof Date) {
|
|
jourDateStr = formatDateToString(j.JourDate);
|
|
} else if (typeof j.JourDate === 'string') {
|
|
// Si c'est déjà une string, extraire juste la partie date (YYYY-MM-DD)
|
|
jourDateStr = j.JourDate.split('T')[0];
|
|
}
|
|
|
|
const match = jourDateStr === dateStr;
|
|
console.log('Comparaison:', jourDateStr, 'vs', dateStr, 'match:', match);
|
|
return match;
|
|
});
|
|
if (found) {
|
|
console.log('✅ Jour trouvé:', dateStr, found);
|
|
}
|
|
return found;
|
|
};
|
|
|
|
const isJourEnConge = (date) => {
|
|
return congesData.some(conge => {
|
|
const start = new Date(conge.startdate);
|
|
const end = new Date(conge.enddate);
|
|
return date >= start && date <= end && conge.statut === 'Valide';
|
|
});
|
|
};
|
|
|
|
// Vérifier si le jour est STRICTEMENT dans le passé (pas aujourd'hui)
|
|
const isPastOnly = (date) => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const checkDate = new Date(date);
|
|
checkDate.setHours(0, 0, 0, 0);
|
|
return checkDate < today;
|
|
};
|
|
|
|
// Vérifier si un jour spécifique est verrouillé
|
|
const isJourVerrouille = (date) => {
|
|
const jourData = getJourData(date);
|
|
return jourData?.Verrouille === true || jourData?.Verrouille === 1;
|
|
};
|
|
|
|
// Afficher message d'info
|
|
const showInfo = (message, type = 'info') => {
|
|
setInfoMessage({ message, type });
|
|
setTimeout(() => setInfoMessage(null), 5000);
|
|
};
|
|
|
|
// Ouvrir le modal de saisie
|
|
const handleJourClick = (date) => {
|
|
if (!date) return; // Ignorer les cases vides
|
|
|
|
if (!isMoisAutorise() && !isRH) {
|
|
showInfo('Vous ne pouvez saisir que pour le mois en cours ou le mois précédent', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!isPastOnly(date)) {
|
|
showInfo('Vous ne pouvez pas saisir le jour actuel. Veuillez attendre demain.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (isHoliday(date)) {
|
|
showInfo(`Jour férié : ${getHolidayName(date)} - Saisie impossible`, 'info');
|
|
return;
|
|
}
|
|
|
|
if (isJourEnConge(date)) {
|
|
showInfo('Vous êtes en congé ce jour - Saisie impossible', 'info');
|
|
return;
|
|
}
|
|
|
|
if (isJourVerrouille(date) && !isRH) {
|
|
showInfo('Ce jour est déjà verrouillé - Contactez les RH pour modification', 'error');
|
|
return;
|
|
}
|
|
|
|
const jourData = getJourData(date);
|
|
setSelectedJour({
|
|
date: date,
|
|
dateStr: formatDateToString(date),
|
|
jourTravaille: jourData?.JourTravaille !== false,
|
|
reposQuotidien: jourData?.ReposQuotidienRespect !== false,
|
|
reposHebdo: jourData?.ReposHebdomadaireRespect !== false,
|
|
commentaire: jourData?.CommentaireRepos || ''
|
|
});
|
|
setShowSaisieModal(true);
|
|
};
|
|
|
|
// Sauvegarder un jour
|
|
const handleSaveJour = async () => {
|
|
if ((!selectedJour.reposQuotidien || !selectedJour.reposHebdo)) {
|
|
if (!selectedJour.commentaire || selectedJour.commentaire.trim() === '') {
|
|
showInfo('Commentaire obligatoire en cas de non-respect des repos', 'warning');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const response = await fetch('/saveCompteRenduJour', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: userId,
|
|
date: selectedJour.dateStr,
|
|
jour_travaille: selectedJour.jourTravaille,
|
|
repos_quotidien: selectedJour.reposQuotidien,
|
|
repos_hebdo: selectedJour.reposHebdo,
|
|
commentaire: selectedJour.commentaire,
|
|
rh_override: isRH
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
await loadCompteRendu();
|
|
await loadStatsAnnuelles();
|
|
setShowSaisieModal(false);
|
|
showInfo('✅ Jour enregistré', 'success');
|
|
console.log('Données rechargées après sauvegarde');
|
|
} else {
|
|
showInfo(data.message || 'Erreur lors de la sauvegarde', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur sauvegarde jour:', error);
|
|
showInfo('Erreur serveur', 'error');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// Saisie en masse
|
|
const handleSaisieMasse = async (joursTravailles) => {
|
|
if (!isMoisAutorise() && !isRH) {
|
|
showInfo('Vous ne pouvez saisir que pour le mois en cours ou le mois précédent', 'warning');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
const response = await fetch('/saveCompteRenduMasse', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
user_id: userId,
|
|
annee: annee,
|
|
mois: mois,
|
|
jours: joursTravailles,
|
|
rh_override: isRH
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
await loadCompteRendu();
|
|
await loadStatsAnnuelles();
|
|
setShowSaisieMasse(false);
|
|
showInfo(data.message || `✅ ${data.count || 0} jours enregistrés`, 'success');
|
|
} else {
|
|
showInfo(data.message || 'Erreur', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur saisie masse:', error);
|
|
showInfo('Erreur serveur', 'error');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
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 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 monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
|
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
|
|
|
const days = getDaysInMonth();
|
|
const moisAutorise = isMoisAutorise();
|
|
|
|
// Vérifier l'accès
|
|
if (!hasAccess()) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex">
|
|
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
|
<div className="flex-1 lg:ml-60 p-8">
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
|
<AlertCircle className="w-12 h-12 text-yellow-600 mx-auto mb-4" />
|
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Accès restreint</h2>
|
|
<p className="text-gray-600 mb-4">
|
|
Cette fonctionnalité est réservée aux :
|
|
</p>
|
|
<ul className="text-gray-700 space-y-2 text-left max-w-md mx-auto">
|
|
<li className="flex items-center gap-2">
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
Collaborateurs en forfait jour
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
Directeurs et directrices de campus
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<Check className="w-4 h-4 text-green-600" />
|
|
Service RH
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex">
|
|
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
|
<div className="flex-1 lg:ml-60 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<RefreshCw className="w-12 h-12 animate-spin text-cyan-600 mx-auto mb-4" />
|
|
<p className="text-gray-600">Chargement...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">
|
|
{/* Message d'information */}
|
|
{infoMessage && (
|
|
<div className={`mb-4 p-4 rounded-lg border-l-4 flex items-center gap-3 animate-slideIn ${infoMessage.type === 'success' ? 'bg-green-50 border-green-500' :
|
|
infoMessage.type === 'error' ? 'bg-red-50 border-red-500' :
|
|
infoMessage.type === 'warning' ? 'bg-orange-50 border-orange-500' :
|
|
'bg-blue-50 border-blue-500'
|
|
}`}>
|
|
<Info className={`w-5 h-5 flex-shrink-0 ${infoMessage.type === 'success' ? 'text-green-600' :
|
|
infoMessage.type === 'error' ? 'text-red-600' :
|
|
infoMessage.type === 'warning' ? 'text-orange-600' :
|
|
'text-blue-600'
|
|
}`} />
|
|
<p className={`text-sm font-medium ${infoMessage.type === 'success' ? 'text-green-800' :
|
|
infoMessage.type === 'error' ? 'text-red-800' :
|
|
infoMessage.type === 'warning' ? 'text-orange-800' :
|
|
'text-blue-800'
|
|
}`}>{infoMessage.message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">
|
|
Compte-Rendu d'Activités
|
|
</h1>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
Forfait jour - Suivi des jours travaillés et repos obligatoires
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
className="lg:hidden p-2 rounded-lg bg-white border"
|
|
>
|
|
<Menu className="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats annuelles */}
|
|
{statsAnnuelles && (
|
|
<div className="bg-gradient-to-r from-cyan-500 to-blue-500 rounded-xl shadow-md p-6 text-white">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<TrendingUp className="w-6 h-6" />
|
|
<h3 className="text-lg font-bold">Cumul annuel {annee}</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="bg-white bg-opacity-20 rounded-lg p-4">
|
|
<p className="text-sm opacity-90">Jours travaillés</p>
|
|
<p className="text-3xl font-bold">{statsAnnuelles.totalJoursTravailles || 0}</p>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bandeau mois non autorisé */}
|
|
{!moisAutorise && !isRH && (
|
|
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 mb-6 rounded">
|
|
<div className="flex items-center gap-3">
|
|
<AlertCircle className="w-6 h-6 text-yellow-600" />
|
|
<div>
|
|
<p className="font-semibold text-yellow-900">Mois non accessible</p>
|
|
<p className="text-sm text-yellow-700">
|
|
Vous pouvez saisir uniquement le mois en cours et le mois précédent (mais pas le jour actuel).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation + Actions */}
|
|
<div className="bg-white rounded-lg border p-4 mb-6">
|
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigateMonth('prev')}
|
|
className="p-2 hover:bg-gray-100 rounded"
|
|
>
|
|
<ChevronLeft className="w-5 h-5 text-blue-600" />
|
|
</button>
|
|
<h2 className="text-xl font-semibold min-w-[200px] text-center">
|
|
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
|
</h2>
|
|
<button
|
|
onClick={() => navigateMonth('next')}
|
|
className="p-2 hover:bg-gray-100 rounded"
|
|
>
|
|
<ChevronRight className="w-5 h-5 text-blue-600" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{mensuelData && (
|
|
<span className="text-sm text-gray-600">
|
|
{mensuelData.NbJoursTravailles || 0} jours saisis
|
|
</span>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setShowSaisieMasse(true)}
|
|
disabled={!moisAutorise && !isRH}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
<FileText className="w-4 h-4" />
|
|
<span className="hidden sm:inline">Saisie en masse</span>
|
|
</button>
|
|
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendrier */}
|
|
<div className="bg-white rounded-lg border overflow-hidden shadow-sm">
|
|
<div className="grid grid-cols-6 gap-2 p-4 bg-gray-50">
|
|
{['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'].map(day => (
|
|
<div key={day} className="text-center font-semibold text-gray-700 text-sm">
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-6 gap-2 p-4">
|
|
{days.map((date, index) => {
|
|
// Case vide pour le décalage
|
|
if (date === null) {
|
|
return (
|
|
<div key={`empty-${index}`} className="min-h-[100px] p-3"></div>
|
|
);
|
|
}
|
|
|
|
const jourData = getJourData(date);
|
|
const enConge = isJourEnConge(date);
|
|
const ferie = isHoliday(date);
|
|
const isPast = isPastOnly(date);
|
|
const isToday = date.toDateString() === new Date().toDateString();
|
|
const jourVerrouille = isJourVerrouille(date);
|
|
|
|
// Déterminer la classe de fond
|
|
let bgClass = 'bg-white hover:bg-gray-50';
|
|
let cursorClass = 'cursor-pointer hover:shadow-md';
|
|
|
|
if (ferie) {
|
|
bgClass = 'bg-gray-700 text-white';
|
|
cursorClass = 'cursor-not-allowed';
|
|
} else if (enConge) {
|
|
bgClass = 'bg-purple-100';
|
|
cursorClass = 'cursor-not-allowed';
|
|
} else if (jourData) {
|
|
bgClass = 'bg-gray-300 hover:bg-gray-400';
|
|
cursorClass = 'cursor-pointer hover:shadow-md';
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
onClick={() => handleJourClick(date)}
|
|
className={`
|
|
min-h-[100px] p-3 rounded-lg border-2 transition-all
|
|
${isToday ? 'border-cyan-500' : 'border-gray-200'}
|
|
${bgClass}
|
|
${cursorClass}
|
|
${!isPast ? 'opacity-40 cursor-not-allowed' : ''}
|
|
${!moisAutorise && !isRH ? 'opacity-60 cursor-not-allowed' : ''}
|
|
`}
|
|
title={
|
|
ferie ? getHolidayName(date) :
|
|
enConge ? 'En congé' :
|
|
jourData ? 'Jour saisi - Cliquer pour modifier' :
|
|
''
|
|
}
|
|
>
|
|
<div className={`text-right text-sm font-semibold mb-2 flex items-center justify-end gap-1 ${ferie ? 'text-white' :
|
|
jourData ? 'text-gray-700' :
|
|
'text-gray-700'
|
|
}`}>
|
|
{date.getDate()}
|
|
{jourData && !ferie && !enConge && (
|
|
<Lock className="w-3 h-3 text-gray-600" />
|
|
)}
|
|
</div>
|
|
|
|
{ferie ? (
|
|
<div className="text-center">
|
|
<div className="text-xs text-white font-bold truncate">{getHolidayName(date)}</div>
|
|
</div>
|
|
) : enConge ? (
|
|
<div className="text-center">
|
|
<div className="text-xs text-purple-700 font-semibold">En congé</div>
|
|
</div>
|
|
) : jourData ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1 text-xs">
|
|
{jourData.JourTravaille ? (
|
|
<Check className="w-3 h-3 text-gray-700" />
|
|
) : (
|
|
<X className="w-3 h-3 text-gray-700" />
|
|
)}
|
|
<span className="text-gray-700 truncate">
|
|
{jourData.JourTravaille ? 'Travaillé' : 'Non travaillé'}
|
|
</span>
|
|
</div>
|
|
|
|
{!jourData.ReposQuotidienRespect && (
|
|
<div className="flex items-center gap-1 text-xs text-gray-700">
|
|
<AlertCircle className="w-3 h-3" />
|
|
<span className="truncate">Repos quotidien</span>
|
|
</div>
|
|
)}
|
|
|
|
{!jourData.ReposHebdomadaireRespect && (
|
|
<div className="flex items-center gap-1 text-xs text-gray-700">
|
|
<AlertCircle className="w-3 h-3" />
|
|
<span className="truncate">Repos hebdo</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-center text-xs text-gray-600 font-semibold mt-2">
|
|
Saisi
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-xs text-gray-400">
|
|
{isPast && moisAutorise ? 'Cliquer pour saisir' : 'Non disponible'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Légende */}
|
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-cyan-50 border-2 border-cyan-500 rounded"></div>
|
|
<span>Aujourd'hui</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-gray-300 border-2 border-gray-200 rounded"></div>
|
|
<Lock className="w-3 h-3 text-gray-600" />
|
|
<span>Jour saisi (grisé)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-purple-100 border-2 border-gray-200 rounded"></div>
|
|
<span>En congé</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 bg-gray-700 rounded"></div>
|
|
<span>Jour férié</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 text-orange-600" />
|
|
<span>Non-respect repos</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal saisie jour */}
|
|
{showSaisieModal && selectedJour && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
|
Saisie du {selectedJour.date.toLocaleDateString('fr-FR', {
|
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
|
|
})}
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{selectedJour.jourTravaille && (
|
|
<>
|
|
<div className="border-t pt-4">
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedJour.reposQuotidien}
|
|
onChange={(e) => setSelectedJour({ ...selectedJour, reposQuotidien: e.target.checked })}
|
|
className="w-5 h-5 text-blue-600 rounded mt-0.5"
|
|
/>
|
|
<div>
|
|
<span className="text-gray-700 font-medium block">
|
|
Respect du repos quotidien
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
11 heures consécutives minimum
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedJour.reposHebdo}
|
|
onChange={(e) => setSelectedJour({ ...selectedJour, reposHebdo: e.target.checked })}
|
|
className="w-5 h-5 text-blue-600 rounded mt-0.5"
|
|
/>
|
|
<div>
|
|
<span className="text-gray-700 font-medium block">
|
|
Respect du repos hebdomadaire
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
35 heures consécutives minimum (24h + 11h)
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
{(!selectedJour.reposQuotidien || !selectedJour.reposHebdo) && (
|
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Commentaire obligatoire
|
|
</label>
|
|
<textarea
|
|
value={selectedJour.commentaire}
|
|
onChange={(e) => setSelectedJour({ ...selectedJour, commentaire: e.target.value })}
|
|
placeholder="Précisez les jours/semaines concernés et les raisons..."
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
rows={4}
|
|
/>
|
|
<p className="text-xs text-orange-700 mt-2">
|
|
Veuillez préciser les circonstances du non-respect des repos obligatoires
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 mt-6">
|
|
<button
|
|
onClick={() => setShowSaisieModal(false)}
|
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
|
disabled={isSaving}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleSaveJour}
|
|
disabled={isSaving}
|
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Enregistrement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4" />
|
|
Enregistrer
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal saisie en masse */}
|
|
{showSaisieMasse && (
|
|
<SaisieMasseModal
|
|
mois={mois}
|
|
annee={annee}
|
|
days={days.filter(d => d !== null)} // Filtrer les cases vides
|
|
congesData={congesData}
|
|
holidays={holidays}
|
|
onClose={() => setShowSaisieMasse(false)}
|
|
onSave={handleSaisieMasse}
|
|
isSaving={isSaving}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Modal de saisie en masse
|
|
const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, onSave, isSaving }) => {
|
|
const [selectedDays, setSelectedDays] = useState([]);
|
|
|
|
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
|
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
|
|
|
const isJourEnConge = (date) => {
|
|
return congesData.some(conge => {
|
|
const start = new Date(conge.startdate);
|
|
const end = new Date(conge.enddate);
|
|
return date >= start && date <= end && conge.statut === 'Valide';
|
|
});
|
|
};
|
|
|
|
const isHoliday = (date) => {
|
|
return holidays.some(holiday =>
|
|
holiday.date.getDate() === date.getDate() &&
|
|
holiday.date.getMonth() === date.getMonth() &&
|
|
holiday.date.getFullYear() === date.getFullYear()
|
|
);
|
|
};
|
|
|
|
const isPastOnly = (date) => {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
date = new Date(date);
|
|
date.setHours(0, 0, 0, 0);
|
|
return date < today;
|
|
};
|
|
|
|
const toggleDay = (date) => {
|
|
const dateStr = formatDateToString(date);
|
|
if (selectedDays.includes(dateStr)) {
|
|
setSelectedDays(selectedDays.filter(d => d !== dateStr));
|
|
} else {
|
|
setSelectedDays([...selectedDays, dateStr]);
|
|
}
|
|
};
|
|
|
|
const selectAllWorkingDays = () => {
|
|
const workingDays = days
|
|
.filter(date => isPastOnly(date) && !isJourEnConge(date) && !isHoliday(date))
|
|
.map(date => formatDateToString(date));
|
|
|
|
setSelectedDays(workingDays);
|
|
};
|
|
|
|
const formatDateToString = (date) => {
|
|
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 handleSave = () => {
|
|
const joursTravailles = selectedDays.map(dateStr => ({
|
|
date: dateStr,
|
|
jour_travaille: true,
|
|
repos_quotidien: true,
|
|
repos_hebdo: true,
|
|
commentaire: ''
|
|
}));
|
|
|
|
onSave(joursTravailles);
|
|
};
|
|
|
|
// Générer les jours avec décalage pour la saisie en masse aussi
|
|
const getDaysWithOffset = () => {
|
|
const year = annee;
|
|
const month = mois - 1;
|
|
const firstDay = new Date(year, month, 1);
|
|
|
|
let firstDayOfWeek = firstDay.getDay();
|
|
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
|
|
|
const daysWithOffset = [];
|
|
|
|
// Ajouter des cases vides pour le décalage
|
|
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
daysWithOffset.push(null);
|
|
}
|
|
|
|
// Ajouter les jours réels
|
|
daysWithOffset.push(...days);
|
|
|
|
return daysWithOffset;
|
|
};
|
|
|
|
const daysWithOffset = getDaysWithOffset();
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-4xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
|
Saisie en masse - {monthNames[mois - 1]} {annee}
|
|
</h3>
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
|
<p className="text-sm text-blue-800">
|
|
Sélectionnez tous les jours travaillés du mois. Le jour actuel, les jours fériés et les congés sont automatiquement exclus.
|
|
Les repos quotidien et hebdomadaire seront considérés comme respectés.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={selectAllWorkingDays}
|
|
className="mb-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Sélectionner tous les jours ouvrés disponibles
|
|
</button>
|
|
|
|
<div className="grid grid-cols-6 gap-2 p-4">
|
|
{daysWithOffset.map((date, index) => {
|
|
// Case vide
|
|
if (date === null) {
|
|
return (
|
|
<div key={`empty-${index}`} className="p-3"></div>
|
|
);
|
|
}
|
|
|
|
const dateStr = formatDateToString(date);
|
|
const enConge = isJourEnConge(date);
|
|
const ferie = isHoliday(date);
|
|
const isPast = isPastOnly(date);
|
|
const isSelected = selectedDays.includes(dateStr);
|
|
const isToday = date.toDateString() === new Date().toDateString();
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
onClick={() => !enConge && !ferie && isPast && toggleDay(date)}
|
|
className={`
|
|
p-3 rounded-lg border-2 text-center cursor-pointer transition-all
|
|
${isToday ? 'border-cyan-500 bg-cyan-100' : ''}
|
|
${ferie ? 'bg-gray-700 text-white cursor-not-allowed' : ''}
|
|
${enConge ? 'bg-purple-100 cursor-not-allowed' : ''}
|
|
${!isPast ? 'opacity-30 cursor-not-allowed' : ''}
|
|
${isSelected ? 'bg-green-500 border-green-600 text-white' : 'bg-white border-gray-200 hover:bg-gray-50'}
|
|
`}
|
|
>
|
|
<div className="font-semibold">{date.getDate()}</div>
|
|
{isToday && <div className="text-xs mt-1">Aujourd'hui</div>}
|
|
{ferie && <div className="text-xs mt-1">Férié</div>}
|
|
{enConge && <div className="text-xs mt-1 text-purple-700">Congé</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-600 mb-4">
|
|
{selectedDays.length} jour{selectedDays.length > 1 ? 's' : ''} sélectionné{selectedDays.length > 1 ? 's' : ''}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
|
disabled={isSaving}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving || selectedDays.length === 0}
|
|
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Enregistrement...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4" />
|
|
Enregistrer {selectedDays.length} jours
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CompteRenduActivites; |