mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-29 07:53:23 +00:00
feat: Amorçage de la gestion des absences [#16]
This commit is contained in:
@ -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()}"
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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"),
|
||||||
]
|
]
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user