feat: Amorçage de la gestion des absences [#16]

This commit is contained in:
N3WT DE COMPET
2025-05-03 23:32:19 +02:00
parent 1c75927bba
commit cb4fe74a9e
5 changed files with 338 additions and 42 deletions

View File

@ -132,3 +132,32 @@ class PaymentMode(models.Model):
def __str__(self): def __str__(self):
return f"{self.get_mode_display()} - {self.get_type_display()}" return f"{self.get_mode_display()} - {self.get_type_display()}"
class AbsenceMoment(models.IntegerChoices):
MORNING = 1, 'Morning'
AFTERNOON = 2, 'Afternoon'
TOTAL = 3, 'Total'
class AbsenceReason(models.IntegerChoices):
JUSTIFIED_ABSENCE = 1, 'Justified Absence'
UNJUSTIFIED_ABSENCE = 2, 'Unjustified Absence'
JUSTIFIED_LATE = 3, 'Justified Late'
UNJUSTIFIED_LATE = 4, 'Unjustified Late'
class AbsenceManagement(models.Model):
day = models.DateField()
moment = models.IntegerField(
choices=AbsenceMoment.choices,
default=AbsenceMoment.TOTAL
)
reason = models.IntegerField(
choices=AbsenceReason.choices,
default=AbsenceReason.UNJUSTIFIED_ABSENCE
)
student = models.ForeignKey(
'Subscriptions.Student',
on_delete=models.CASCADE,
related_name='absences'
)
def __str__(self):
return f"{self.student} - {self.day} - {self.get_moment_display()} - {self.get_reason_display()}"

View File

@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Teacher, Speciality, SchoolClass, Planning, LEVEL_CHOICES, Discount, Fee, PaymentPlan, PaymentMode from .models import Teacher, Speciality, SchoolClass, Planning, LEVEL_CHOICES, Discount, Fee, PaymentPlan, PaymentMode, AbsenceManagement
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
from Subscriptions.models import Student from Subscriptions.models import Student
from Establishment.models import Establishment from Establishment.models import Establishment
@ -8,6 +8,11 @@ from N3wtSchool import settings, bdd
from django.utils import timezone from django.utils import timezone
import pytz import pytz
class AbsenceManagementSerializer(serializers.ModelSerializer):
class Meta:
model = AbsenceManagement
fields = '__all__'
class SpecialitySerializer(serializers.ModelSerializer): class SpecialitySerializer(serializers.ModelSerializer):
updated_date_formatted = serializers.SerializerMethodField() updated_date_formatted = serializers.SerializerMethodField()

View File

@ -16,7 +16,9 @@ from .views import (
PaymentPlanListCreateView, PaymentPlanListCreateView,
PaymentPlanDetailView, PaymentPlanDetailView,
PaymentModeListCreateView, PaymentModeListCreateView,
PaymentModeDetailView PaymentModeDetailView,
AbsenceManagementListCreateView,
AbsenceManagementDetailView
) )
urlpatterns = [ urlpatterns = [
@ -42,5 +44,8 @@ urlpatterns = [
re_path(r'^paymentPlans/(?P<id>[0-9]+)$', PaymentPlanDetailView.as_view(), name="payment_plan_detail"), re_path(r'^paymentPlans/(?P<id>[0-9]+)$', PaymentPlanDetailView.as_view(), name="payment_plan_detail"),
re_path(r'^paymentModes$', PaymentModeListCreateView.as_view(), name="payment_mode_list_create"), re_path(r'^paymentModes$', PaymentModeListCreateView.as_view(), name="payment_mode_list_create"),
re_path(r'^paymentModes/(?P<id>[0-9]+)$', PaymentModeDetailView.as_view(), name="payment_mode_detail") re_path(r'^paymentModes/(?P<id>[0-9]+)$', PaymentModeDetailView.as_view(), name="payment_mode_detail"),
re_path(r'^absences$', AbsenceManagementListCreateView.as_view(), name="absence_list_create"),
re_path(r'^absences/(?P<id>[0-9]+)$', AbsenceManagementDetailView.as_view(), name="absence_detail"),
] ]

View File

@ -12,7 +12,8 @@ from .models import (
Discount, Discount,
Fee, Fee,
PaymentPlan, PaymentPlan,
PaymentMode PaymentMode,
AbsenceManagement
) )
from .serializers import ( from .serializers import (
TeacherSerializer, TeacherSerializer,
@ -22,7 +23,8 @@ from .serializers import (
DiscountSerializer, DiscountSerializer,
FeeSerializer, FeeSerializer,
PaymentPlanSerializer, PaymentPlanSerializer,
PaymentModeSerializer PaymentModeSerializer,
AbsenceManagementSerializer
) )
from N3wtSchool.bdd import delete_object, getAllObjects, getObject from N3wtSchool.bdd import delete_object, getAllObjects, getObject
@ -411,3 +413,48 @@ class PaymentModeDetailView(APIView):
def delete(self, request, id): def delete(self, request, id):
return delete_object(PaymentMode, id) return delete_object(PaymentMode, id)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class AbsenceManagementListCreateView(APIView):
def get(self, request):
absences = AbsenceManagement.objects.all()
serializer = AbsenceManagementSerializer(absences, many=True)
return JsonResponse(serializer.data, safe=False, status=status.HTTP_200_OK)
def post(self, request):
serializer = AbsenceManagementSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class AbsenceManagementDetailView(APIView):
def get(self, request, id):
try:
absence = AbsenceManagement.objects.get(id=id)
serializer = AbsenceManagementSerializer(absence)
return JsonResponse(serializer.data, safe=False, status=status.HTTP_200_OK)
except AbsenceManagement.DoesNotExist:
return JsonResponse({"error": "Absence not found"}, safe=False, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
absence = AbsenceManagement.objects.get(id=id)
serializer = AbsenceManagementSerializer(absence, data=request.data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_200_OK)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
except AbsenceManagement.DoesNotExist:
return JsonResponse({"error": "Absence not found"}, safe=False, status=status.HTTP_404_NOT_FOUND)
def delete(self, request, id):
try:
absence = AbsenceManagement.objects.get(id=id)
absence.delete()
return JsonResponse(safe=False, status=status.HTTP_204_NO_CONTENT)
except AbsenceManagement.DoesNotExist:
return JsonResponse({"error": "Absence not found"}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -11,6 +11,8 @@ import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import Button from '@/components/Button';
import SelectChoice from '@/components/SelectChoice';
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -29,6 +31,33 @@ export default function Page() {
const [popupMessage, setPopupMessage] = useState(''); const [popupMessage, setPopupMessage] = useState('');
const [selectedLevels, setSelectedLevels] = useState([]); // Par défaut, tous les niveaux sont sélectionnés const [selectedLevels, setSelectedLevels] = useState([]); // Par défaut, tous les niveaux sont sélectionnés
const [filteredStudents, setFilteredStudents] = useState([]); 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
// AbsenceMoment constants
const AbsenceMoment = {
MORNING: { value: 1, label: 'Morning' },
AFTERNOON: { value: 2, label: 'Afternoon' },
TOTAL: { value: 3, label: 'Total' },
};
// 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' },
};
useEffect(() => {
console.log('Absences enregistrées :', absences);
}, [absences]);
useEffect(() => { useEffect(() => {
// Initialiser les niveaux sélectionnés avec tous les niveaux disponibles // Initialiser les niveaux sélectionnés avec tous les niveaux disponibles
@ -50,6 +79,17 @@ export default function Page() {
} }
}, [selectedLevels, classe]); }, [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;
}, {});
setAttendance(initialAttendance);
}
}, [filteredStudents]);
const handleCreateGroup = () => { const handleCreateGroup = () => {
if (!newGroup.name || !newGroup.level || !newGroup.students.length) { if (!newGroup.name || !newGroup.level || !newGroup.students.length) {
setPopupMessage( setPopupMessage(
@ -73,6 +113,75 @@ export default function Page() {
); );
}; };
const handleToggleAttendanceMode = () => {
setIsEditingAttendance((prev) => !prev); // Basculer entre mode édition et visualisation
};
const handleValidateAttendance = () => {
console.log('Présence validée :', attendance);
console.log('Absences enregistrées :', absences);
// Exemple : Envoyer les absences à une API
Object.entries(absences).forEach(([studentId, absenceData]) => {
saveAbsence(studentId, absenceData);
});
setIsEditingAttendance(false); // Revenir au mode visualisation
};
const handleAttendanceChange = (studentId) => {
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
if (!updatedAttendance[studentId]) {
setAbsences((prev) => ({
...prev,
[studentId]: {
day: '',
reason: null,
moment: null,
},
}));
} else {
// Si l'élève est recoché, supprimer ses données d'absence
setAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
}
return updatedAttendance;
});
};
const saveAbsence = async (studentId, absenceData) => {
try {
const response = await fetch('/api/absences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
studentId,
...absenceData,
}),
});
if (!response.ok) {
throw new Error("Erreur lors de l'enregistrement de l'absence");
}
console.log(`Absence pour l'élève ${studentId} enregistrée avec succès.`);
} catch (error) {
console.error('Erreur :', error);
}
};
const requestErrorHandler = (err) => { const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err); logger.error('Error fetching data:', err);
}; };
@ -151,11 +260,27 @@ export default function Page() {
</div> </div>
{/* Section Élèves */} {/* Section Élèves */}
<div className="bg-white p-4 rounded-lg shadow-md"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold mb-4 flex items-center"> <h2 className="text-xl font-semibold flex items-center">
<Users className="w-6 h-6 mr-2" /> <Users className="w-6 h-6 mr-2" />
Élèves Élèves
</h2> </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"
/>
)}
</div>
<Table <Table
columns={[ columns={[
{ {
@ -188,10 +313,95 @@ export default function Page() {
{ name: 'Nom', transform: (row) => row.last_name }, { name: 'Nom', transform: (row) => row.last_name },
{ name: 'Prénom', transform: (row) => row.first_name }, { name: 'Prénom', transform: (row) => row.first_name },
{ name: 'Niveau', transform: (row) => getNiveauLabel(row.level) }, { name: 'Niveau', transform: (row) => getNiveauLabel(row.level) },
...(isEditingAttendance
? [
{
name: 'Présent',
transform: (row) => (
<input
type="checkbox"
checked={attendance[row.id] || false}
onChange={() => handleAttendanceChange(row.id)}
className="w-5 h-5"
/>
),
},
{
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>
),
},
]
: []),
]} ]}
data={filteredStudents} // Utiliser les élèves filtrés data={filteredStudents} // Utiliser les élèves filtrés
/> />
</div>
{/* Popup */} {/* Popup */}
<Popup <Popup