feat: Gestion des absences du jour [#16]

This commit is contained in:
N3WT DE COMPET
2025-05-04 12:08:05 +02:00
parent 1bccc85951
commit 030d19d411
13 changed files with 516 additions and 311 deletions

View File

@ -1,10 +1,8 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Plus, Users, Layers, CheckCircle } from 'lucide-react';
import { Users, Layers, CheckCircle, Clock } from 'lucide-react';
import Table from '@/components/Table';
import MultiSelect from '@/components/MultiSelect';
import InputText from '@/components/InputText';
import Popup from '@/components/Popup';
import { fetchClasse } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation';
@ -13,6 +11,17 @@ import { useClasses } from '@/context/ClassesContext';
import { BASE_URL } from '@/utils/Url';
import Button from '@/components/Button';
import SelectChoice from '@/components/SelectChoice';
import CheckBox from '@/components/CheckBox';
import InputText from '@/components/InputText';
import {
fetchAbsences,
createAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
export default function Page() {
const searchParams = useSearchParams();
@ -20,56 +29,67 @@ export default function Page() {
const [classe, setClasse] = useState([]);
const { getNiveauxLabels, getNiveauLabel } = useClasses();
const [students, setStudents] = useState([]);
const [groups, setGroups] = useState([]);
const [newGroup, setNewGroup] = useState({
name: '',
level: null,
students: [],
});
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const [selectedLevels, setSelectedLevels] = useState([]); // Par défaut, tous les niveaux sont sélectionnés
const [filteredStudents, setFilteredStudents] = useState([]);
const [isEditingAttendance, setIsEditingAttendance] = useState(false); // État pour le mode édition
const [attendance, setAttendance] = useState({}); // État pour les cases cochées
const [absences, setAbsences] = useState({}); // État pour stocker les absences
const [absenceDetails, setAbsenceDetails] = useState({
day: '',
reason: null,
moment: null,
}); // Détails d'absence
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
// AbsenceMoment constants
const AbsenceMoment = {
MORNING: { value: 1, label: 'Morning' },
AFTERNOON: { value: 2, label: 'Afternoon' },
TOTAL: { value: 3, label: 'Total' },
MORNING: { value: 1, label: 'Matinée' },
AFTERNOON: { value: 2, label: 'Après-midi' },
TOTAL: { value: 3, label: 'Journée' },
};
// AbsenceReason constants
const AbsenceReason = {
JUSTIFIED_ABSENCE: { value: 1, label: 'Justified Absence' },
UNJUSTIFIED_ABSENCE: { value: 2, label: 'Unjustified Absence' },
JUSTIFIED_LATE: { value: 3, label: 'Justified Late' },
UNJUSTIFIED_LATE: { value: 4, label: 'Unjustified Late' },
JUSTIFIED_ABSENCE: { value: 1, label: 'Absence justifiée' },
UNJUSTIFIED_ABSENCE: { value: 2, label: 'Absence non justifiée' },
JUSTIFIED_LATE: { value: 3, label: 'Retard justifié' },
UNJUSTIFIED_LATE: { value: 4, label: 'Retard non justifié' },
};
useEffect(() => {
console.log('Absences enregistrées :', absences);
}, [absences]);
// Récupérer les données de la classe et initialiser les élèves filtrés
if (schoolClassId) {
fetchClasse(schoolClassId)
.then((classeData) => {
logger.debug('Classes récupérées :', classeData);
setClasse(classeData);
setFilteredStudents(classeData.students); // Initialiser les élèves filtrés
setSelectedLevels(getNiveauxLabels(classeData.levels)); // Initialiser les niveaux sélectionnés
})
.catch(requestErrorHandler);
}
}, [schoolClassId]);
useEffect(() => {
// Initialiser les niveaux sélectionnés avec tous les niveaux disponibles
if (classe?.levels?.length > 0) {
const initialLevels = getNiveauxLabels(classe.levels);
setSelectedLevels(initialLevels);
// Récupérer les absences pour l'établissement sélectionné
if (selectedEstablishmentId) {
fetchAbsences(selectedEstablishmentId)
.then((data) => {
const absencesById = data.reduce((acc, absence) => {
acc[absence.student] = absence;
return acc;
}, {});
setFetchedAbsences(absencesById);
})
.catch((error) =>
logger.error('Erreur lors de la récupération des absences :', error)
);
}
}, [classe]);
}, [selectedEstablishmentId]);
useEffect(() => {
// Filtrer les élèves en fonction des niveaux sélectionnés
if (selectedLevels.length > 0) {
if (classe && selectedLevels.length > 0) {
const filtered = classe.students.filter((student) =>
selectedLevels.includes(getNiveauLabel(student.level))
);
@ -80,29 +100,34 @@ export default function Page() {
}, [selectedLevels, classe]);
useEffect(() => {
// Initialiser l'état des cases cochées avec tous les élèves présents par défaut
if (filteredStudents.length > 0) {
const initialAttendance = filteredStudents.reduce((acc, student) => {
acc[student.id] = true; // Tous les élèves sont cochés par défaut
return acc;
}, {});
// Initialiser `attendance` et `formAbsences` en fonction des élèves filtrés et des absences
if (filteredStudents.length > 0 && fetchedAbsences) {
const today = new Date().toISOString().split('T')[0];
const initialAttendance = {};
const initialFormAbsences = {};
filteredStudents.forEach((student) => {
const existingAbsence =
fetchedAbsences[student.id] &&
fetchedAbsences[student.id].day === today
? fetchedAbsences[student.id]
: null;
if (existingAbsence) {
// Si une absence existe pour aujourd'hui, décocher la case et pré-remplir les champs
initialAttendance[student.id] = false;
initialFormAbsences[student.id] = { ...existingAbsence };
} else {
// Sinon, cocher la case par défaut
initialAttendance[student.id] = true;
}
});
setAttendance(initialAttendance);
setFormAbsences(initialFormAbsences);
}
}, [filteredStudents]);
const handleCreateGroup = () => {
if (!newGroup.name || !newGroup.level || !newGroup.students.length) {
setPopupMessage(
'Tous les champs doivent être remplis pour créer un groupe.'
);
setPopupVisible(true);
return;
}
const updatedGroups = [...groups, newGroup];
setGroups(updatedGroups);
setNewGroup({ name: '', level: null, students: [] });
};
}, [filteredStudents, fetchedAbsences]);
const handleLevelClick = (label) => {
setSelectedLevels(
@ -118,11 +143,17 @@ export default function Page() {
};
const handleValidateAttendance = () => {
console.log('Présence validée :', attendance);
console.log('Absences enregistrées :', absences);
// Filtrer les absences modifiées uniquement pour les étudiants décochés (absents)
const absencesToUpdate = Object.entries(formAbsences).filter(
([studentId, absenceData]) =>
!attendance[studentId] && // L'étudiant est décoché (absent)
JSON.stringify(absenceData) !==
JSON.stringify(fetchedAbsences[studentId]) // Les données ont été modifiées
);
// Exemple : Envoyer les absences à une API
Object.entries(absences).forEach(([studentId, absenceData]) => {
// Envoyer les absences modifiées à une API
absencesToUpdate.forEach(([studentId, absenceData]) => {
console.log('Modification absence élève : ', studentId);
saveAbsence(studentId, absenceData);
});
@ -130,25 +161,70 @@ export default function Page() {
};
const handleAttendanceChange = (studentId) => {
const today = new Date().toISOString().split('T')[0]; // Obtenir la date actuelle au format YYYY-MM-DD
setAttendance((prev) => {
const updatedAttendance = {
...prev,
[studentId]: !prev[studentId], // Inverser l'état de présence
};
// Si l'élève est décoché (absent), initialiser les champs d'absence
// Si l'élève est décoché (absent)
if (!updatedAttendance[studentId]) {
setAbsences((prev) => ({
...prev,
[studentId]: {
day: '',
reason: null,
moment: null,
},
}));
// Vérifier s'il existe une absence pour le jour actuel
const existingAbsence = Object.values(fetchedAbsences).find(
(absence) => absence.student === studentId && absence.day === today
);
if (existingAbsence) {
// Afficher l'absence existante pour le jour actuel
setFormAbsences((prev) => ({
...prev,
[studentId]: {
...existingAbsence,
},
}));
} else {
// Initialiser des champs vides pour créer une nouvelle absence
setFormAbsences((prev) => ({
...prev,
[studentId]: {
day: today,
reason: null,
moment: null,
},
}));
}
} else {
// Si l'élève est recoché, supprimer ses données d'absence
setAbsences((prev) => {
// Si l'élève est recoché (présent), supprimer l'absence existante
const existingAbsence = Object.values(fetchedAbsences).find(
(absence) => absence.student === studentId && absence.day === today
);
if (existingAbsence) {
// Appeler la fonction pour supprimer l'absence
deleteAbsences(existingAbsence.id, csrfToken)
.then(() => {
console.log(
`Absence pour l'élève ${studentId} supprimée avec succès.`
);
// Mettre à jour les absences récupérées
setFetchedAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
})
.catch((error) => {
console.error(
`Erreur lors de la suppression de l'absence pour l'élève ${studentId}:`,
error
);
});
}
// Supprimer les données d'absence dans `formAbsences`
setFormAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
@ -159,26 +235,64 @@ export default function Page() {
});
};
const saveAbsence = async (studentId, absenceData) => {
try {
const response = await fetch('/api/absences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
studentId,
...absenceData,
}),
});
const saveAbsence = (studentId, absenceData) => {
if (!absenceData.reason || !studentId || !absenceData.moment) {
console.error('Tous les champs requis doivent être fournis.');
return;
}
if (!response.ok) {
throw new Error("Erreur lors de l'enregistrement de l'absence");
}
const payload = {
student: studentId,
day: absenceData.day,
reason: absenceData.reason,
moment: absenceData.moment,
establishment: selectedEstablishmentId,
};
console.log(`Absence pour l'élève ${studentId} enregistrée avec succès.`);
} catch (error) {
console.error('Erreur :', error);
if (absenceData.id) {
// Modifier une absence existante
editAbsences(absenceData.id, payload, csrfToken)
.then(() => {
console.log(
`Absence pour l'élève ${studentId} modifiée avec succès.`
);
// Mettre à jour fetchedAbsences et formAbsences localement
setFetchedAbsences((prev) => ({
...prev,
[studentId]: { ...prev[studentId], ...payload },
}));
setFormAbsences((prev) => ({
...prev,
[studentId]: { ...prev[studentId], ...payload },
}));
})
.catch((error) => {
console.error(
`Erreur lors de la modification de l'absence pour l'élève ${studentId}:`,
error
);
});
} else {
// Créer une nouvelle absence
createAbsences(payload, csrfToken)
.then((response) => {
console.log(`Absence pour l'élève ${studentId} créée avec succès.`);
// Mettre à jour fetchedAbsences et formAbsences localement
setFetchedAbsences((prev) => ({
...prev,
[studentId]: { id: response.id, ...payload },
}));
setFormAbsences((prev) => ({
...prev,
[studentId]: { id: response.id, ...payload },
}));
})
.catch((error) => {
console.error(
`Erreur lors de la création de l'absence pour l'élève ${studentId}:`,
error
);
});
}
};
@ -186,15 +300,7 @@ export default function Page() {
logger.error('Error fetching data:', err);
};
useEffect(() => {
fetchClasse(schoolClassId)
.then((classeData) => {
logger.debug('Classes récupérées :', classeData);
setClasse(classeData);
setFilteredStudents(classeData.students);
})
.catch(requestErrorHandler);
}, []);
const today = new Date().toISOString().split('T')[0]; // Obtenez la date actuelle au format YYYY-MM-DD
return (
<div className="p-6 space-y-6">
@ -259,146 +365,185 @@ export default function Page() {
</div>
</div>
{/* Section Élèves */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold flex items-center">
<Users className="w-6 h-6 mr-2" />
Élèves
</h2>
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2"
/>
)}
{/* Affichage de la date du jour */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
<Clock className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold text-gray-800">
Appel du jour :{' '}
<span className="ml-2 text-emerald-600">{today}</span>
</h2>
</div>
<div className="flex items-center">
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
)}
</div>
</div>
<Table
columns={[
{
name: 'photo',
name: 'Nom',
transform: (row) => (
<div className="flex justify-center items-center">
{row.photo ? (
<a
href={`${BASE_URL}${row.photo}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.photo}`}
alt={`${row.first_name} ${row.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
</a>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
<span className="text-gray-500 text-sm font-semibold">
{row.first_name[0]}
{row.last_name[0]}
</span>
</div>
)}
</div>
<div className="text-center">{row.last_name}</div>
),
},
{
name: 'Prénom',
transform: (row) => (
<div className="text-center">{row.first_name}</div>
),
},
{
name: 'Niveau',
transform: (row) => (
<div className="text-center">{getNiveauLabel(row.level)}</div>
),
},
{ name: 'Nom', transform: (row) => row.last_name },
{ name: 'Prénom', transform: (row) => row.first_name },
{ name: 'Niveau', transform: (row) => getNiveauLabel(row.level) },
...(isEditingAttendance
? [
{
name: 'Présent',
name: "Gestion de l'appel",
transform: (row) => (
<input
type="checkbox"
checked={attendance[row.id] || false}
onChange={() => handleAttendanceChange(row.id)}
className="w-5 h-5"
/>
<div className="flex flex-col gap-2 items-center">
{/* Case à cocher pour la présence */}
<CheckBox
item={{ id: row.id }}
formData={{
attendance: attendance[row.id] ? [row.id] : [],
}}
handleChange={() => handleAttendanceChange(row.id)}
fieldName="attendance"
/>
{/* Champs pour le motif et le moment */}
{!attendance[row.id] && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mt-2">
<SelectChoice
name={`reason-${row.id}`}
label=""
placeHolder="Motif"
selected={formAbsences[row.id]?.reason || ''}
callback={(e) =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
reason: parseInt(e.target.value, 10),
},
}))
}
choices={Object.values(AbsenceReason).map(
(reason) => ({
value: reason.value,
label: reason.label,
})
)}
/>
<SelectChoice
name={`moment-${row.id}`}
label=""
placeHolder="Durée"
selected={formAbsences[row.id]?.moment || ''}
callback={(e) =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
moment: parseInt(e.target.value, 10),
},
}))
}
choices={Object.values(AbsenceMoment).map(
(moment) => ({
value: moment.value,
label: moment.label,
})
)}
/>
</div>
)}
</div>
),
},
{
name: "Détails d'absence",
transform: (row) =>
!attendance[row.id] && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{/* Champ pour le jour */}
<input
type="date"
value={absences[row.id]?.day || ''}
onChange={(e) =>
setAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
day: e.target.value,
},
}))
}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm"
/>
{/* Champ pour le motif */}
<SelectChoice
name={`reason-${row.id}`}
label=""
placeHolder="Motif"
selected={absences[row.id]?.reason || ''}
callback={(e) =>
setAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
reason: parseInt(e.target.value, 10),
},
}))
}
choices={Object.values(AbsenceReason).map(
(reason) => ({
value: reason.value,
label: reason.label,
})
)}
required
/>
{/* Champ pour le moment */}
<SelectChoice
name={`moment-${row.id}`}
label=""
placeHolder="Moment"
selected={absences[row.id]?.moment || ''}
callback={(e) =>
setAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
moment: parseInt(e.target.value, 10),
},
}))
}
choices={Object.values(AbsenceMoment).map(
(moment) => ({
value: moment.value,
label: moment.label,
})
)}
required
/>
</div>
),
},
]
: []),
: [
{
name: 'Statut',
transform: (row) => {
const today = new Date().toISOString().split('T')[0];
const absence =
formAbsences[row.id] ||
Object.values(fetchedAbsences).find(
(absence) =>
absence.student === row.id && absence.day === today
);
if (!absence) {
return (
<div className="text-center text-green-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Présent
</div>
);
}
switch (absence.reason) {
case AbsenceReason.JUSTIFIED_LATE.value:
return (
<div className="text-center text-yellow-500 flex justify-center items-center gap-2">
<Clock className="w-5 h-5" />
Retard justifié
</div>
);
case AbsenceReason.UNJUSTIFIED_LATE.value:
return (
<div className="text-center text-red-500 flex justify-center items-center gap-2">
<Clock className="w-5 h-5" />
Retard non justifié
</div>
);
case AbsenceReason.JUSTIFIED_ABSENCE.value:
return (
<div className="text-center text-blue-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Absence justifiée
</div>
);
case AbsenceReason.UNJUSTIFIED_ABSENCE.value:
return (
<div className="text-center text-red-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Absence non justifiée
</div>
);
default:
return (
<div className="text-center text-gray-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Statut inconnu
</div>
);
}
},
},
]),
]}
data={filteredStudents} // Utiliser les élèves filtrés
/>

View File

@ -37,6 +37,7 @@ export default function Page() {
useEffect(() => {
if (selectedEstablishmentId) {
setIsLoading(true);
fetchClasses(selectedEstablishmentId)
.then((classesData) => {
logger.debug('Classes récupérées :', classesData);
@ -47,6 +48,7 @@ export default function Page() {
);
setClasses(filteredClasses); // Mettre à jour les classes filtrées
setIsLoading(false);
})
.catch(requestErrorHandler);
}
@ -71,7 +73,7 @@ export default function Page() {
});
};
if (isLoading || classes.length === 0) {
if (isLoading) {
return <Loader />;
}