From 905fa5dbfb3d50710a3aa04d9b28664c1c86b991 Mon Sep 17 00:00:00 2001 From: N3WT DE COMPET Date: Fri, 3 Apr 2026 22:10:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Ajout=20d'un=20syst=C3=A8me=20de=20nota?= =?UTF-8?q?tion=20par=20classe=20et=20par=20mati=C3=A8re=20et=20par=20?= =?UTF-8?q?=C3=A9l=C3=A8ve=20[N3WTS-6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Back-End/School/models.py | 45 +- Back-End/School/serializers.py | 34 +- Back-End/School/urls.py | 11 + Back-End/School/views.py | 184 +++++++- Back-End/start.py | 3 + .../[locale]/admin/grades/[studentId]/page.js | 60 ++- .../src/app/[locale]/admin/grades/page.js | 403 ++++++++++++++++-- .../structure/SchoolClassManagement/page.js | 325 ++++++++++++-- Front-End/src/app/actions/schoolAction.js | 70 +++ .../components/Evaluation/EvaluationForm.js | 158 +++++++ .../Evaluation/EvaluationGradeTable.js | 299 +++++++++++++ .../components/Evaluation/EvaluationList.js | 153 +++++++ .../Evaluation/EvaluationStudentView.js | 298 +++++++++++++ Front-End/src/components/Evaluation/index.js | 4 + Front-End/src/utils/Url.js | 2 + 15 files changed, 1970 insertions(+), 79 deletions(-) create mode 100644 Front-End/src/components/Evaluation/EvaluationForm.js create mode 100644 Front-End/src/components/Evaluation/EvaluationGradeTable.js create mode 100644 Front-End/src/components/Evaluation/EvaluationList.js create mode 100644 Front-End/src/components/Evaluation/EvaluationStudentView.js create mode 100644 Front-End/src/components/Evaluation/index.js diff --git a/Back-End/School/models.py b/Back-End/School/models.py index b9bffc8..44427ad 100644 --- a/Back-End/School/models.py +++ b/Back-End/School/models.py @@ -155,4 +155,47 @@ class EstablishmentCompetency(models.Model): def __str__(self): if self.competency: return f"{self.establishment.name} - {self.competency.name}" - return f"{self.establishment.name} - {self.custom_name} (custom)" \ No newline at end of file + return f"{self.establishment.name} - {self.custom_name} (custom)" + + +class Evaluation(models.Model): + """ + Définition d'une évaluation (contrôle, examen, etc.) associée à une matière et une classe. + """ + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + speciality = models.ForeignKey(Speciality, on_delete=models.CASCADE, related_name='evaluations') + school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE, related_name='evaluations') + period = models.CharField(max_length=20, help_text="Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026") + date = models.DateField(null=True, blank=True) + max_score = models.DecimalField(max_digits=5, decimal_places=2, default=20) + coefficient = models.DecimalField(max_digits=3, decimal_places=2, default=1) + establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='evaluations') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-date', '-created_at'] + + def __str__(self): + return f"{self.name} - {self.speciality.name} ({self.school_class.atmosphere_name})" + + +class StudentEvaluation(models.Model): + """ + Note d'un élève pour une évaluation. + """ + student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='evaluation_scores') + evaluation = models.ForeignKey(Evaluation, on_delete=models.CASCADE, related_name='student_scores') + score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + comment = models.TextField(blank=True) + is_absent = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('student', 'evaluation') + + def __str__(self): + score_display = 'Absent' if self.is_absent else self.score + return f"{self.student} - {self.evaluation.name}: {score_display}" \ No newline at end of file diff --git a/Back-End/School/serializers.py b/Back-End/School/serializers.py index fc5a566..b927297 100644 --- a/Back-End/School/serializers.py +++ b/Back-End/School/serializers.py @@ -10,7 +10,9 @@ from .models import ( PaymentPlan, PaymentMode, EstablishmentCompetency, - Competency + Competency, + Evaluation, + StudentEvaluation ) from Auth.models import Profile, ProfileRole from Subscriptions.models import Student @@ -304,4 +306,32 @@ class PaymentPlanSerializer(serializers.ModelSerializer): class PaymentModeSerializer(serializers.ModelSerializer): class Meta: model = PaymentMode - fields = '__all__' \ No newline at end of file + fields = '__all__' + + +class EvaluationSerializer(serializers.ModelSerializer): + speciality_name = serializers.CharField(source='speciality.name', read_only=True) + speciality_color = serializers.CharField(source='speciality.color_code', read_only=True) + school_class_name = serializers.CharField(source='school_class.atmosphere_name', read_only=True) + + class Meta: + model = Evaluation + fields = '__all__' + + +class StudentEvaluationSerializer(serializers.ModelSerializer): + student_name = serializers.SerializerMethodField() + student_first_name = serializers.CharField(source='student.first_name', read_only=True) + student_last_name = serializers.CharField(source='student.last_name', read_only=True) + evaluation_name = serializers.CharField(source='evaluation.name', read_only=True) + max_score = serializers.DecimalField(source='evaluation.max_score', read_only=True, max_digits=5, decimal_places=2) + speciality_name = serializers.CharField(source='evaluation.speciality.name', read_only=True) + speciality_color = serializers.CharField(source='evaluation.speciality.color', read_only=True) + period = serializers.CharField(source='evaluation.period', read_only=True) + + class Meta: + model = StudentEvaluation + fields = '__all__' + + def get_student_name(self, obj): + return f"{obj.student.last_name} {obj.student.first_name}" \ No newline at end of file diff --git a/Back-End/School/urls.py b/Back-End/School/urls.py index 9cec102..641d830 100644 --- a/Back-End/School/urls.py +++ b/Back-End/School/urls.py @@ -11,6 +11,8 @@ from .views import ( PaymentModeListCreateView, PaymentModeDetailView, CompetencyListCreateView, CompetencyDetailView, EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView, + EvaluationListCreateView, EvaluationDetailView, + StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView, ) urlpatterns = [ @@ -43,4 +45,13 @@ urlpatterns = [ re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"), re_path(r'^establishmentCompetencies/(?P[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"), + + # Evaluations + re_path(r'^evaluations$', EvaluationListCreateView.as_view(), name="evaluation_list_create"), + re_path(r'^evaluations/(?P[0-9]+)$', EvaluationDetailView.as_view(), name="evaluation_detail"), + + # Student Evaluations + re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"), + re_path(r'^studentEvaluations/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"), + re_path(r'^studentEvaluations/(?P[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"), ] \ No newline at end of file diff --git a/Back-End/School/views.py b/Back-End/School/views.py index 5353133..d5843c0 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -16,7 +16,9 @@ from .models import ( PaymentPlan, PaymentMode, EstablishmentCompetency, - Competency + Competency, + Evaluation, + StudentEvaluation ) from .serializers import ( TeacherSerializer, @@ -28,7 +30,9 @@ from .serializers import ( PaymentPlanSerializer, PaymentModeSerializer, EstablishmentCompetencySerializer, - CompetencySerializer + CompetencySerializer, + EvaluationSerializer, + StudentEvaluationSerializer ) from Common.models import Domain, Category from N3wtSchool.bdd import delete_object, getAllObjects, getObject @@ -785,3 +789,179 @@ class EstablishmentCompetencyDetailView(APIView): return JsonResponse({'message': 'Deleted'}, safe=False) except EstablishmentCompetency.DoesNotExist: return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + +# ===================== EVALUATIONS ===================== + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class EvaluationListCreateView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + establishment_id = request.GET.get('establishment_id') + school_class_id = request.GET.get('school_class') + period = request.GET.get('period') + + if not establishment_id: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + evaluations = Evaluation.objects.filter(establishment_id=establishment_id) + + if school_class_id: + evaluations = evaluations.filter(school_class_id=school_class_id) + if period: + evaluations = evaluations.filter(period=period) + + serializer = EvaluationSerializer(evaluations, many=True) + return JsonResponse(serializer.data, safe=False) + + def post(self, request): + data = JSONParser().parse(request) + serializer = EvaluationSerializer(data=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 EvaluationDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, id): + try: + evaluation = Evaluation.objects.get(id=id) + serializer = EvaluationSerializer(evaluation) + return JsonResponse(serializer.data, safe=False) + except Evaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + try: + evaluation = Evaluation.objects.get(id=id) + except Evaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + data = JSONParser().parse(request) + serializer = EvaluationSerializer(evaluation, data=data, partial=True) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data, safe=False) + return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + try: + evaluation = Evaluation.objects.get(id=id) + evaluation.delete() + return JsonResponse({'message': 'Deleted'}, safe=False) + except Evaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + +# ===================== STUDENT EVALUATIONS ===================== + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class StudentEvaluationListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + student_id = request.GET.get('student_id') + evaluation_id = request.GET.get('evaluation_id') + period = request.GET.get('period') + school_class_id = request.GET.get('school_class_id') + + student_evals = StudentEvaluation.objects.all() + + if student_id: + student_evals = student_evals.filter(student_id=student_id) + if evaluation_id: + student_evals = student_evals.filter(evaluation_id=evaluation_id) + if period: + student_evals = student_evals.filter(evaluation__period=period) + if school_class_id: + student_evals = student_evals.filter(evaluation__school_class_id=school_class_id) + + serializer = StudentEvaluationSerializer(student_evals, many=True) + return JsonResponse(serializer.data, safe=False) + + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class StudentEvaluationBulkUpdateView(APIView): + """ + Mise à jour en masse des notes des élèves pour une évaluation. + Attendu dans le body : + [ + { "student_id": 1, "evaluation_id": 1, "score": 15.5, "comment": "", "is_absent": false }, + ... + ] + """ + permission_classes = [IsAuthenticated] + + def put(self, request): + data = JSONParser().parse(request) + if not isinstance(data, list): + data = [data] + + updated = [] + errors = [] + + for item in data: + student_id = item.get('student_id') + evaluation_id = item.get('evaluation_id') + + if not student_id or not evaluation_id: + errors.append({'error': 'student_id et evaluation_id sont requis', 'item': item}) + continue + + try: + student_eval, created = StudentEvaluation.objects.update_or_create( + student_id=student_id, + evaluation_id=evaluation_id, + defaults={ + 'score': item.get('score'), + 'comment': item.get('comment', ''), + 'is_absent': item.get('is_absent', False) + } + ) + updated.append(StudentEvaluationSerializer(student_eval).data) + except Exception as e: + errors.append({'error': str(e), 'item': item}) + + return JsonResponse({'updated': updated, 'errors': errors}, safe=False) + + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class StudentEvaluationDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, id): + try: + student_eval = StudentEvaluation.objects.get(id=id) + serializer = StudentEvaluationSerializer(student_eval) + return JsonResponse(serializer.data, safe=False) + except StudentEvaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + try: + student_eval = StudentEvaluation.objects.get(id=id) + except StudentEvaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + data = JSONParser().parse(request) + serializer = StudentEvaluationSerializer(student_eval, data=data, partial=True) + if serializer.is_valid(): + serializer.save() + return JsonResponse(serializer.data, safe=False) + return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + try: + student_eval = StudentEvaluation.objects.get(id=id) + student_eval.delete() + return JsonResponse({'message': 'Deleted'}, safe=False) + except StudentEvaluation.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) diff --git a/Back-End/start.py b/Back-End/start.py index 249fcbd..2b87249 100644 --- a/Back-End/start.py +++ b/Back-End/start.py @@ -13,8 +13,11 @@ def run_command(command): test_mode = os.getenv('test_mode', 'false').lower() == 'true' flush_data = os.getenv('flush_data', 'false').lower() == 'true' +#flush_data=True migrate_data = os.getenv('migrate_data', 'false').lower() == 'true' +migrate_data=True watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true' +watch_mode=True collect_static_cmd = [ ["python", "manage.py", "collectstatic", "--noinput"] diff --git a/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js index e00d9f9..19393d5 100644 --- a/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js +++ b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js @@ -5,6 +5,7 @@ import SelectChoice from '@/components/Form/SelectChoice'; import Attendance from '@/components/Grades/Attendance'; import GradesStatsCircle from '@/components/Grades/GradesStatsCircle'; import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart'; +import { EvaluationStudentView } from '@/components/Evaluation'; import Button from '@/components/Form/Button'; import logger from '@/utils/logger'; import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url'; @@ -15,9 +16,14 @@ import { editAbsences, deleteAbsences, } from '@/app/actions/subscriptionAction'; +import { + fetchEvaluations, + fetchStudentEvaluations, + updateStudentEvaluation, +} from '@/app/actions/schoolAction'; import { useEstablishment } from '@/context/EstablishmentContext'; import { useClasses } from '@/context/ClassesContext'; -import { Award, ArrowLeft } from 'lucide-react'; +import { Award, ArrowLeft, BookOpen } from 'lucide-react'; import dayjs from 'dayjs'; import { useCsrfToken } from '@/context/CsrfContext'; @@ -46,6 +52,10 @@ export default function StudentGradesPage() { const [selectedPeriod, setSelectedPeriod] = useState(null); const [allAbsences, setAllAbsences] = useState([]); + // Evaluation states + const [evaluations, setEvaluations] = useState([]); + const [studentEvaluationsData, setStudentEvaluationsData] = useState([]); + const getPeriods = () => { if (selectedEstablishmentEvaluationFrequency === 1) { return [ @@ -135,6 +145,26 @@ export default function StudentGradesPage() { } }, [selectedEstablishmentId]); + // Load evaluations for the student + useEffect(() => { + if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) { + const periodString = getPeriodString( + selectedPeriod, + selectedEstablishmentEvaluationFrequency + ); + + // Load evaluations for the class + fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString) + .then((data) => setEvaluations(data)) + .catch((error) => logger.error('Erreur lors du fetch des évaluations:', error)); + + // Load student's evaluation scores + fetchStudentEvaluations(studentId, null, periodString, null) + .then((data) => setStudentEvaluationsData(data)) + .catch((error) => logger.error('Erreur lors du fetch des notes:', error)); + } + }, [student, selectedPeriod, selectedEstablishmentId]); + const absences = React.useMemo(() => { return allAbsences .filter((a) => a.student === studentId) @@ -176,6 +206,18 @@ export default function StudentGradesPage() { ); }; + const handleUpdateGrade = async (studentEvalId, data) => { + try { + await updateStudentEvaluation(studentEvalId, data, csrfToken); + // Reload student evaluations + const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency); + const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null); + setStudentEvaluationsData(updatedData); + } catch (error) { + logger.error('Erreur lors de la modification de la note:', error); + } + }; + return (
{/* Header */} @@ -280,6 +322,22 @@ export default function StudentGradesPage() {
+ + {/* Évaluations par matière */} +
+
+ +

+ Évaluations par matière +

+
+ +
); diff --git a/Front-End/src/app/[locale]/admin/grades/page.js b/Front-End/src/app/[locale]/admin/grades/page.js index 7a5783e..3c5e632 100644 --- a/Front-End/src/app/[locale]/admin/grades/page.js +++ b/Front-End/src/app/[locale]/admin/grades/page.js @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Award, Eye, Search } from 'lucide-react'; +import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save } from 'lucide-react'; import SectionHeader from '@/components/SectionHeader'; import Table from '@/components/Table'; import logger from '@/utils/logger'; @@ -15,8 +15,10 @@ import { fetchStudentCompetencies, fetchAbsences, } from '@/app/actions/subscriptionAction'; +import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluation } from '@/app/actions/schoolAction'; import { useEstablishment } from '@/context/EstablishmentContext'; import { useClasses } from '@/context/ClassesContext'; +import { useCsrfToken } from '@/context/CsrfContext'; import dayjs from 'dayjs'; function getPeriodString(periodValue, frequency) { @@ -28,18 +30,22 @@ function getPeriodString(periodValue, frequency) { return ''; } -function calcPercent(data) { +function calcCompetencyStats(data) { if (!data?.data) return null; const scores = []; data.data.forEach((d) => d.categories.forEach((c) => - c.competences.forEach((comp) => scores.push(comp.score ?? 0)) + c.competences.forEach((comp) => scores.push(comp.score)) ) ); if (!scores.length) return null; - return Math.round( - (scores.filter((s) => s === 3).length / scores.length) * 100 - ); + const total = scores.length; + return { + acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), + inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), + notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100), + notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100), + }; } function getPeriodColumns(frequency) { @@ -58,6 +64,13 @@ function getPeriodColumns(frequency) { return []; } +const COMPETENCY_COLUMNS = [ + { key: 'acquired', label: 'Acquises', color: 'bg-emerald-100 text-emerald-700' }, + { key: 'inProgress', label: 'En cours', color: 'bg-yellow-100 text-yellow-700' }, + { key: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' }, + { key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' }, +]; + function getCurrentPeriodValue(frequency) { const periods = { @@ -81,18 +94,19 @@ function getCurrentPeriodValue(frequency) { return current?.value ?? null; } -function PercentBadge({ value, loading }) { +function PercentBadge({ value, loading, color }) { if (loading) return ; if (value === null) return ; - const color = + const badgeColor = color || ( value >= 75 ? 'bg-emerald-100 text-emerald-700' : value >= 50 ? 'bg-yellow-100 text-yellow-700' - : 'bg-red-100 text-red-600'; + : 'bg-red-100 text-red-600' + ); return ( {value}% @@ -101,6 +115,7 @@ function PercentBadge({ value, loading }) { export default function Page() { const router = useRouter(); + const csrfToken = useCsrfToken(); const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment(); const { getNiveauLabel } = useClasses(); @@ -111,6 +126,12 @@ export default function Page() { const [statsMap, setStatsMap] = useState({}); const [statsLoading, setStatsLoading] = useState(false); const [absencesMap, setAbsencesMap] = useState({}); + const [gradesModalStudent, setGradesModalStudent] = useState(null); + const [studentEvaluations, setStudentEvaluations] = useState([]); + const [gradesLoading, setGradesLoading] = useState(false); + const [editingEvalId, setEditingEvalId] = useState(null); + const [editScore, setEditScore] = useState(''); + const [editAbsent, setEditAbsent] = useState(false); const periodColumns = getPeriodColumns( selectedEstablishmentEvaluationFrequency @@ -138,7 +159,7 @@ export default function Page() { .catch((error) => logger.error('Error fetching absences:', error)); }, [selectedEstablishmentId]); - // Fetch stats for all students × all periods + // Fetch stats for all students - aggregate all periods useEffect(() => { if (!students.length || !selectedEstablishmentEvaluationFrequency) return; @@ -156,15 +177,32 @@ export default function Page() { Promise.all(tasks).then((results) => { const map = {}; - results.forEach(({ studentId, periodValue, data }) => { - if (!map[studentId]) map[studentId] = {}; - map[studentId][periodValue] = calcPercent(data); + // Group by student and aggregate all competency scores across periods + const studentScores = {}; + results.forEach(({ studentId, data }) => { + if (!studentScores[studentId]) studentScores[studentId] = []; + if (data?.data) { + data.data.forEach((d) => + d.categories.forEach((c) => + c.competences.forEach((comp) => studentScores[studentId].push(comp.score)) + ) + ); + } }); - Object.keys(map).forEach((id) => { - const vals = Object.values(map[id]).filter((v) => v !== null); - map[id].global = vals.length - ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) - : null; + // Calculate stats for each student + Object.keys(studentScores).forEach((studentId) => { + const scores = studentScores[studentId]; + if (!scores.length) { + map[studentId] = null; + } else { + const total = scores.length; + map[studentId] = { + acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), + inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), + notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100), + notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100), + }; + } }); setStatsMap(map); setStatsLoading(false); @@ -189,6 +227,81 @@ export default function Page() { currentPage * ITEMS_PER_PAGE ); + const openGradesModal = (e, student) => { + e.stopPropagation(); + setGradesModalStudent(student); + setGradesLoading(true); + fetchStudentEvaluations(student.id) + .then((data) => { + setStudentEvaluations(data || []); + setGradesLoading(false); + }) + .catch((error) => { + logger.error('Error fetching student evaluations:', error); + setStudentEvaluations([]); + setGradesLoading(false); + }); + }; + + const closeGradesModal = () => { + setGradesModalStudent(null); + setStudentEvaluations([]); + setEditingEvalId(null); + }; + + const startEditingEval = (evalItem) => { + setEditingEvalId(evalItem.id); + setEditScore(evalItem.score ?? ''); + setEditAbsent(evalItem.is_absent ?? false); + }; + + const cancelEditingEval = () => { + setEditingEvalId(null); + setEditScore(''); + setEditAbsent(false); + }; + + const handleSaveEval = async (evalItem) => { + try { + await updateStudentEvaluation(evalItem.id, { + score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), + is_absent: editAbsent, + }, csrfToken); + // Update local state + setStudentEvaluations((prev) => + prev.map((e) => + e.id === evalItem.id + ? { ...e, score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), is_absent: editAbsent } + : e + ) + ); + cancelEditingEval(); + } catch (error) { + logger.error('Error updating evaluation:', error); + } + }; + + const handleDeleteEval = async (evalItem) => { + if (!confirm('Supprimer cette note ?')) return; + try { + await deleteStudentEvaluation(evalItem.id, csrfToken); + setStudentEvaluations((prev) => prev.filter((e) => e.id !== evalItem.id)); + } catch (error) { + logger.error('Error deleting evaluation:', error); + } + }; + + // Group evaluations by subject + const groupedBySubject = studentEvaluations.reduce((acc, evalItem) => { + const subjectName = evalItem.speciality_name || 'Sans matière'; + const subjectColor = evalItem.speciality_color || '#6B7280'; + if (!acc[subjectName]) { + acc[subjectName] = { color: subjectColor, evaluations: [] }; + } + acc[subjectName].evaluations.push(evalItem); + return acc; + }, {}); + const handleEvaluer = (e, studentId) => { e.stopPropagation(); const periodStr = getPeriodString( @@ -205,8 +318,7 @@ export default function Page() { { name: 'Élève', transform: () => null }, { name: 'Niveau', transform: () => null }, { name: 'Classe', transform: () => null }, - ...periodColumns.map(({ label }) => ({ name: label, transform: () => null })), - { name: 'Stat globale', transform: () => null }, + ...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })), { name: 'Absences', transform: () => null }, { name: 'Actions', transform: () => null }, ]; @@ -261,13 +373,6 @@ export default function Page() { ) : ( student.associated_class_name ); - case 'Stat globale': - return ( - - ); case 'Absences': return absencesMap[student.id] ? ( @@ -287,6 +392,14 @@ export default function Page() { Fiche + + + + {/* Content */} +
+ {gradesLoading ? ( +
+
+
+ ) : Object.keys(groupedBySubject).length === 0 ? ( +
+ Aucune note enregistrée pour cet élève. +
+ ) : ( +
+ {/* Résumé des moyennes */} + {(() => { + const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => { + const scores = evaluations + .filter(e => e.score !== null && e.score !== undefined && !e.is_absent) + .map(e => parseFloat(e.score)) + .filter(s => !isNaN(s)); + const avg = scores.length + ? scores.reduce((sum, s) => sum + s, 0) / scores.length + : null; + return { subject, color, avg }; + }).filter(s => s.avg !== null && !isNaN(s.avg)); + + const overallAvg = subjectAverages.length + ? (subjectAverages.reduce((sum, s) => sum + s.avg, 0) / subjectAverages.length).toFixed(1) + : null; + + return ( +
+
+ Résumé + {overallAvg !== null && ( + + Moyenne générale : {overallAvg}/20 + + )} +
+
+ {subjectAverages.map(({ subject, color, avg }) => ( +
+ + {subject} + + {avg.toFixed(1)} + +
+ ))} +
+
+ ); + })()} + + {Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => { + const scores = evaluations + .filter(e => e.score !== null && e.score !== undefined && !e.is_absent) + .map(e => parseFloat(e.score)) + .filter(s => !isNaN(s)); + const avg = scores.length + ? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1) + : null; + return ( +
+
+
+ + {subject} +
+ {avg !== null && ( + + Moyenne : {avg} + + )} +
+ + + + + + + + + + + {evaluations.map((evalItem) => { + const isEditing = editingEvalId === evalItem.id; + return ( + + + + + + + );})} + +
ÉvaluationPériodeNoteActions
+ {evalItem.evaluation_name || 'Évaluation'} + + {evalItem.period || '—'} + + {isEditing ? ( +
+ + {!editAbsent && ( + setEditScore(e.target.value)} + min="0" + max={evalItem.max_score || 20} + step="0.5" + className="w-16 text-center px-1 py-0.5 border rounded text-sm" + /> + )} + /{evalItem.max_score || 20} +
+ ) : evalItem.is_absent ? ( + Absent + ) : evalItem.score !== null ? ( + + {evalItem.score}/{evalItem.max_score || 20} + + ) : ( + + )} +
+ {isEditing ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+ +
+ + + )} ); } diff --git a/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js b/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js index e01b141..a8ee078 100644 --- a/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js +++ b/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js @@ -1,10 +1,10 @@ 'use client'; import React, { useState, useEffect } from 'react'; -import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react'; +import { Users, Layers, CheckCircle, Clock, XCircle, ClipboardList, Plus } from 'lucide-react'; import Table from '@/components/Table'; import Popup from '@/components/Popup'; -import { fetchClasse } from '@/app/actions/schoolAction'; +import { fetchClasse, fetchSpecialities, fetchEvaluations, createEvaluation, updateEvaluation, deleteEvaluation, fetchStudentEvaluations, saveStudentEvaluations, deleteStudentEvaluation } from '@/app/actions/schoolAction'; import { useSearchParams } from 'next/navigation'; import logger from '@/utils/logger'; import { useClasses } from '@/context/ClassesContext'; @@ -17,10 +17,12 @@ import { editAbsences, deleteAbsences, } from '@/app/actions/subscriptionAction'; +import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation'; import { useCsrfToken } from '@/context/CsrfContext'; import { useEstablishment } from '@/context/EstablishmentContext'; import { useNotification } from '@/context/NotificationContext'; +import dayjs from 'dayjs'; export default function Page() { const searchParams = useSearchParams(); @@ -38,8 +40,53 @@ export default function Page() { const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement + // Tab system + const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations' + + // Evaluation states + const [specialities, setSpecialities] = useState([]); + const [evaluations, setEvaluations] = useState([]); + const [studentEvaluations, setStudentEvaluations] = useState([]); + const [showEvaluationForm, setShowEvaluationForm] = useState(false); + const [selectedEvaluation, setSelectedEvaluation] = useState(null); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [editingEvaluation, setEditingEvaluation] = useState(null); + const csrfToken = useCsrfToken(); - const { selectedEstablishmentId } = useEstablishment(); + const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment(); + + // Périodes selon la fréquence d'évaluation + const getPeriods = () => { + const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; + const nextYear = (year + 1).toString(); + const schoolYear = `${year}-${nextYear}`; + + if (selectedEstablishmentEvaluationFrequency === 1) { + return [ + { label: 'Trimestre 1', value: `T1_${schoolYear}` }, + { label: 'Trimestre 2', value: `T2_${schoolYear}` }, + { label: 'Trimestre 3', value: `T3_${schoolYear}` }, + ]; + } + if (selectedEstablishmentEvaluationFrequency === 2) { + return [ + { label: 'Semestre 1', value: `S1_${schoolYear}` }, + { label: 'Semestre 2', value: `S2_${schoolYear}` }, + ]; + } + if (selectedEstablishmentEvaluationFrequency === 3) { + return [{ label: 'Année', value: `A_${schoolYear}` }]; + } + return []; + }; + + // Auto-select current period + useEffect(() => { + const periods = getPeriods(); + if (periods.length > 0 && !selectedPeriod) { + setSelectedPeriod(periods[0].value); + } + }, [selectedEstablishmentEvaluationFrequency]); // AbsenceMoment constants const AbsenceMoment = { @@ -158,6 +205,87 @@ export default function Page() { } }, [filteredStudents, fetchedAbsences]); + // Load specialities for evaluations + useEffect(() => { + if (selectedEstablishmentId) { + fetchSpecialities(selectedEstablishmentId) + .then((data) => setSpecialities(data)) + .catch((error) => logger.error('Erreur lors du chargement des matières:', error)); + } + }, [selectedEstablishmentId]); + + // Load evaluations when tab is active and period is selected + useEffect(() => { + if (activeTab === 'evaluations' && selectedEstablishmentId && schoolClassId && selectedPeriod) { + fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod) + .then((data) => setEvaluations(data)) + .catch((error) => logger.error('Erreur lors du chargement des évaluations:', error)); + } + }, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]); + + // Load student evaluations when grading + useEffect(() => { + if (selectedEvaluation && schoolClassId) { + fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId) + .then((data) => setStudentEvaluations(data)) + .catch((error) => logger.error('Erreur lors du chargement des notes:', error)); + } + }, [selectedEvaluation, schoolClassId]); + + // Handlers for evaluations + const handleCreateEvaluation = async (data) => { + try { + await createEvaluation(data, csrfToken); + showNotification('Évaluation créée avec succès', 'success', 'Succès'); + setShowEvaluationForm(false); + // Reload evaluations + const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod); + setEvaluations(updatedEvaluations); + } catch (error) { + logger.error('Erreur lors de la création:', error); + showNotification('Erreur lors de la création', 'error', 'Erreur'); + } + }; + + const handleEditEvaluation = (evaluation) => { + setEditingEvaluation(evaluation); + setShowEvaluationForm(true); + }; + + const handleUpdateEvaluation = async (data) => { + try { + await updateEvaluation(editingEvaluation.id, data, csrfToken); + showNotification('Évaluation modifiée avec succès', 'success', 'Succès'); + setShowEvaluationForm(false); + setEditingEvaluation(null); + // Reload evaluations + const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod); + setEvaluations(updatedEvaluations); + } catch (error) { + logger.error('Erreur lors de la modification:', error); + showNotification('Erreur lors de la modification', 'error', 'Erreur'); + } + }; + + const handleDeleteEvaluation = async (evaluationId) => { + await deleteEvaluation(evaluationId, csrfToken); + // Reload evaluations + const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod); + setEvaluations(updatedEvaluations); + }; + + const handleSaveGrades = async (gradesData) => { + await saveStudentEvaluations(gradesData, csrfToken); + // Reload student evaluations + const updatedStudentEvaluations = await fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId); + setStudentEvaluations(updatedStudentEvaluations); + }; + + const handleDeleteGrade = async (studentEvalId) => { + await deleteStudentEvaluation(studentEvalId, csrfToken); + setStudentEvaluations((prev) => prev.filter((se) => se.id !== studentEvalId)); + }; + const handleLevelClick = (label) => { setSelectedLevels( (prev) => @@ -474,48 +602,83 @@ export default function Page() { - {/* Affichage de la date du jour */} -
-
-
- -
-

- Appel du jour :{' '} - {today} -

-
-
- {!isEditingAttendance ? ( - +
- ( -
{row.last_name}
- ), - }, - { - name: 'Prénom', - transform: (row) => ( -
{row.first_name}
+ {/* Tab Content: Attendance */} + {activeTab === 'attendance' && ( + <> + {/* Affichage de la date du jour */} +
+
+
+ +
+

+ Appel du jour :{' '} + {today} +

+
+
+ {!isEditingAttendance ? ( +
+
+ +
( +
{row.last_name}
+ ), + }, + { + name: 'Prénom', + transform: (row) => ( +
{row.first_name}
), }, { @@ -728,6 +891,84 @@ export default function Page() { ]} data={filteredStudents} // Utiliser les élèves filtrés /> + + )} + + {/* Tab Content: Evaluations */} + {activeTab === 'evaluations' && ( +
+ {/* Header avec sélecteur de période et bouton d'ajout */} +
+
+
+ +

+ Évaluations de la classe +

+
+
+
+ setSelectedPeriod(e.target.value)} + /> +
+
+
+
+ + {/* Formulaire de création/édition d'évaluation */} + {showEvaluationForm && ( + { + setShowEvaluationForm(false); + setEditingEvaluation(null); + }} + /> + )} + + {/* Liste des évaluations */} +
+ setSelectedEvaluation(evaluation)} + /> +
+ + {/* Modal de notation */} + {selectedEvaluation && ( +
+
+ setSelectedEvaluation(null)} + onDeleteGrade={handleDeleteGrade} + /> +
+
+ )} +
+ )} {/* Popup */} { headers: { 'X-CSRFToken': csrfToken }, }); }; + +// ===================== EVALUATIONS ===================== + +export const fetchEvaluations = (establishmentId, schoolClassId = null, period = null) => { + let url = `${BE_SCHOOL_EVALUATIONS_URL}?establishment_id=${establishmentId}`; + if (schoolClassId) url += `&school_class=${schoolClassId}`; + if (period) url += `&period=${period}`; + return fetchWithAuth(url); +}; + +export const createEvaluation = (data, csrfToken) => { + return fetchWithAuth(BE_SCHOOL_EVALUATIONS_URL, { + method: 'POST', + headers: { 'X-CSRFToken': csrfToken }, + body: JSON.stringify(data), + }); +}; + +export const updateEvaluation = (id, data, csrfToken) => { + return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, { + method: 'PUT', + headers: { 'X-CSRFToken': csrfToken }, + body: JSON.stringify(data), + }); +}; + +export const deleteEvaluation = (id, csrfToken) => { + return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, { + method: 'DELETE', + headers: { 'X-CSRFToken': csrfToken }, + }); +}; + +// ===================== STUDENT EVALUATIONS ===================== + +export const fetchStudentEvaluations = (studentId = null, evaluationId = null, period = null, schoolClassId = null) => { + let url = `${BE_SCHOOL_STUDENT_EVALUATIONS_URL}?`; + const params = []; + if (studentId) params.push(`student_id=${studentId}`); + if (evaluationId) params.push(`evaluation_id=${evaluationId}`); + if (period) params.push(`period=${period}`); + if (schoolClassId) params.push(`school_class_id=${schoolClassId}`); + url += params.join('&'); + return fetchWithAuth(url); +}; + +export const saveStudentEvaluations = (data, csrfToken) => { + return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/bulk`, { + method: 'PUT', + headers: { 'X-CSRFToken': csrfToken }, + body: JSON.stringify(data), + }); +}; + +export const updateStudentEvaluation = (id, data, csrfToken) => { + return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, { + method: 'PUT', + headers: { 'X-CSRFToken': csrfToken }, + body: JSON.stringify(data), + }); +}; + +export const deleteStudentEvaluation = (id, csrfToken) => { + return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, { + method: 'DELETE', + headers: { 'X-CSRFToken': csrfToken }, + }); +}; diff --git a/Front-End/src/components/Evaluation/EvaluationForm.js b/Front-End/src/components/Evaluation/EvaluationForm.js new file mode 100644 index 0000000..52e62f0 --- /dev/null +++ b/Front-End/src/components/Evaluation/EvaluationForm.js @@ -0,0 +1,158 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import InputText from '@/components/Form/InputText'; +import SelectChoice from '@/components/Form/SelectChoice'; +import Button from '@/components/Form/Button'; +import { Plus, Save, X } from 'lucide-react'; + +export default function EvaluationForm({ + specialities, + period, + schoolClassId, + establishmentId, + initialValues, + onSubmit, + onCancel, +}) { + const isEditing = !!initialValues; + + const [form, setForm] = useState({ + name: '', + speciality: '', + date: '', + max_score: '20', + coefficient: '1', + description: '', + }); + + useEffect(() => { + if (initialValues) { + setForm({ + name: initialValues.name || '', + speciality: initialValues.speciality?.toString() || '', + date: initialValues.date || '', + max_score: initialValues.max_score?.toString() || '20', + coefficient: initialValues.coefficient?.toString() || '1', + description: initialValues.description || '', + }); + } + }, [initialValues]); + + const [errors, setErrors] = useState({}); + + const validate = () => { + const newErrors = {}; + if (!form.name.trim()) newErrors.name = 'Le nom est requis'; + if (!form.speciality) newErrors.speciality = 'La matière est requise'; + return newErrors; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const validationErrors = validate(); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + onSubmit({ + name: form.name, + speciality: Number(form.speciality), + school_class: schoolClassId, + establishment: establishmentId, + period: period, + date: form.date || null, + max_score: parseFloat(form.max_score) || 20, + coefficient: parseFloat(form.coefficient) || 1, + description: form.description, + }); + }; + + return ( +
+
+

+ {isEditing ? 'Modifier l\'évaluation' : 'Nouvelle évaluation'} +

+ +
+ + setForm({ ...form, name: e.target.value })} + errorMsg={errors.name} + required + /> + + ({ value: s.id, label: s.name }))} + selected={form.speciality} + callback={(e) => setForm({ ...form, speciality: e.target.value })} + errorMsg={errors.speciality} + required + /> + + setForm({ ...form, date: e.target.value })} + /> + +
+
+ setForm({ ...form, max_score: e.target.value })} + /> +
+
+ setForm({ ...form, coefficient: e.target.value })} + /> +
+
+ + setForm({ ...form, description: e.target.value })} + /> + +
+
+ + ); +} diff --git a/Front-End/src/components/Evaluation/EvaluationGradeTable.js b/Front-End/src/components/Evaluation/EvaluationGradeTable.js new file mode 100644 index 0000000..8de191f --- /dev/null +++ b/Front-End/src/components/Evaluation/EvaluationGradeTable.js @@ -0,0 +1,299 @@ +'use client'; +import React, { useState, useEffect } from 'react'; +import { Save, X, UserX, Trash2 } from 'lucide-react'; +import Button from '@/components/Form/Button'; +import CheckBox from '@/components/Form/CheckBox'; +import { useNotification } from '@/context/NotificationContext'; + +export default function EvaluationGradeTable({ + evaluation, + students, + studentEvaluations, + onSave, + onClose, + onDeleteGrade, +}) { + const [grades, setGrades] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const { showNotification } = useNotification(); + + // Initialiser les notes à partir des données existantes + useEffect(() => { + const initialGrades = {}; + students.forEach((student) => { + const existingEval = studentEvaluations.find( + (se) => se.student === student.id && se.evaluation === evaluation.id + ); + initialGrades[student.id] = { + score: existingEval?.score ?? '', + comment: existingEval?.comment ?? '', + is_absent: existingEval?.is_absent ?? false, + }; + }); + setGrades(initialGrades); + }, [students, studentEvaluations, evaluation]); + + const handleScoreChange = (studentId, value) => { + const numValue = value === '' ? '' : parseFloat(value); + if (value !== '' && (numValue < 0 || numValue > evaluation.max_score)) { + return; + } + setGrades((prev) => ({ + ...prev, + [studentId]: { + ...prev[studentId], + score: value, + is_absent: false, + }, + })); + }; + + const handleAbsentToggle = (studentId) => { + setGrades((prev) => ({ + ...prev, + [studentId]: { + ...prev[studentId], + is_absent: !prev[studentId]?.is_absent, + score: !prev[studentId]?.is_absent ? '' : prev[studentId]?.score, + }, + })); + }; + + const handleCommentChange = (studentId, value) => { + setGrades((prev) => ({ + ...prev, + [studentId]: { + ...prev[studentId], + comment: value, + }, + })); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const dataToSave = Object.entries(grades).map(([studentId, data]) => ({ + student_id: parseInt(studentId), + evaluation_id: evaluation.id, + score: data.score === '' ? null : parseFloat(data.score), + comment: data.comment, + is_absent: data.is_absent, + })); + + await onSave(dataToSave); + showNotification('Notes enregistrées avec succès', 'success', 'Succès'); + } catch (error) { + showNotification('Erreur lors de la sauvegarde', 'error', 'Erreur'); + } finally { + setIsSaving(false); + } + }; + + // Calculer les statistiques + const stats = React.useMemo(() => { + const validScores = Object.values(grades) + .filter((g) => g.score !== '' && !g.is_absent) + .map((g) => parseFloat(g.score)); + + if (validScores.length === 0) return null; + + const sum = validScores.reduce((a, b) => a + b, 0); + const avg = sum / validScores.length; + const min = Math.min(...validScores); + const max = Math.max(...validScores); + const absentCount = Object.values(grades).filter((g) => g.is_absent).length; + + return { avg, min, max, count: validScores.length, absentCount }; + }, [grades]); + + return ( +
+ {/* Header */} +
+
+
+

+ {evaluation.name} +

+
+ {evaluation.speciality_name} + + Note max: {evaluation.max_score} + {evaluation.date && ( + <> + + + {new Date(evaluation.date).toLocaleDateString('fr-FR')} + + + )} +
+
+ +
+
+ + {/* Table */} +
+
+ + + + + + + {onDeleteGrade && ( + + )} + + + + {students.map((student) => { + const studentGrade = grades[student.id] || {}; + const isAbsent = studentGrade.is_absent; + const existingEval = studentEvaluations.find( + (se) => se.student === student.id && se.evaluation === evaluation.id + ); + + return ( + + + + + + {onDeleteGrade && ( + + )} + + ); + })} + +
+ Élève + + Note / {evaluation.max_score} + + Absent + + Commentaire + + Actions +
+
+ {student.last_name} {student.first_name} +
+
+ + handleScoreChange(student.id, e.target.value) + } + disabled={isAbsent} + className={`w-20 px-2 py-1 text-center border rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 ${ + isAbsent + ? 'bg-gray-100 text-gray-400 cursor-not-allowed' + : 'border-gray-300' + }`} + /> + + + + + handleCommentChange(student.id, e.target.value) + } + placeholder="Commentaire..." + className="w-full px-2 py-1 border border-gray-300 rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" + /> + + {existingEval && ( + + )} +
+ + + {/* Footer avec statistiques et boutons */} +
+
+ {/* Statistiques */} + {stats && ( +
+ + Moyenne:{' '} + + {stats.avg.toFixed(2)} + + + + Min:{' '} + {stats.min} + + + Max:{' '} + {stats.max} + + + Notés: {stats.count}/{students.length} + + {stats.absentCount > 0 && ( + + Absents: {stats.absentCount} + + )} +
+ )} + + {/* Boutons */} +
+
+
+
+ + ); +} diff --git a/Front-End/src/components/Evaluation/EvaluationList.js b/Front-End/src/components/Evaluation/EvaluationList.js new file mode 100644 index 0000000..0393804 --- /dev/null +++ b/Front-End/src/components/Evaluation/EvaluationList.js @@ -0,0 +1,153 @@ +'use client'; +import React, { useState } from 'react'; +import { Trash2, Edit2, ClipboardList, ChevronDown, ChevronUp } from 'lucide-react'; +import Button from '@/components/Form/Button'; +import Popup from '@/components/Popup'; +import { useNotification } from '@/context/NotificationContext'; + +export default function EvaluationList({ + evaluations, + onDelete, + onEdit, + onGradeStudents, +}) { + const [expandedId, setExpandedId] = useState(null); + const [deletePopupVisible, setDeletePopupVisible] = useState(false); + const [evaluationToDelete, setEvaluationToDelete] = useState(null); + const { showNotification } = useNotification(); + + const handleDeleteClick = (evaluation) => { + setEvaluationToDelete(evaluation); + setDeletePopupVisible(true); + }; + + const handleConfirmDelete = () => { + if (evaluationToDelete && onDelete) { + onDelete(evaluationToDelete.id) + .then(() => { + showNotification('Évaluation supprimée avec succès', 'success', 'Succès'); + setDeletePopupVisible(false); + setEvaluationToDelete(null); + }) + .catch((error) => { + showNotification('Erreur lors de la suppression', 'error', 'Erreur'); + }); + } + }; + + // Grouper les évaluations par matière + const groupedBySpeciality = evaluations.reduce((acc, ev) => { + const key = ev.speciality_name || 'Sans matière'; + if (!acc[key]) { + acc[key] = { + name: key, + color: ev.speciality_color || '#6B7280', + evaluations: [], + }; + } + acc[key].evaluations.push(ev); + return acc; + }, {}); + + if (evaluations.length === 0) { + return ( +
+ Aucune évaluation créée pour cette période +
+ ); + } + + return ( +
+ {Object.values(groupedBySpeciality).map((group) => ( +
+
+ setExpandedId(expandedId === group.name ? null : group.name) + } + > +
+ + {group.name} + + ({group.evaluations.length} évaluation + {group.evaluations.length > 1 ? 's' : ''}) + +
+ {expandedId === group.name ? ( + + ) : ( + + )} +
+ + {expandedId === group.name && ( +
+ {group.evaluations.map((evaluation) => ( +
+
+
+ {evaluation.name} +
+
+ {evaluation.date && ( + + {new Date(evaluation.date).toLocaleDateString('fr-FR')} + + )} + Note max: {evaluation.max_score} + Coef: {evaluation.coefficient} +
+
+
+ + +
+
+ ))} +
+ )} +
+ ))} + + { + setDeletePopupVisible(false); + setEvaluationToDelete(null); + }} + /> +
+ ); +} diff --git a/Front-End/src/components/Evaluation/EvaluationStudentView.js b/Front-End/src/components/Evaluation/EvaluationStudentView.js new file mode 100644 index 0000000..cc5b712 --- /dev/null +++ b/Front-End/src/components/Evaluation/EvaluationStudentView.js @@ -0,0 +1,298 @@ +'use client'; +import React, { useState } from 'react'; +import { BookOpen, TrendingUp, TrendingDown, Minus, Pencil, Trash2, Save, X } from 'lucide-react'; + +export default function EvaluationStudentView({ + evaluations, + studentEvaluations, + onUpdateGrade, + onDeleteGrade, + editable = false +}) { + const [editingId, setEditingId] = useState(null); + const [editScore, setEditScore] = useState(''); + const [editComment, setEditComment] = useState(''); + const [editAbsent, setEditAbsent] = useState(false); + + if (!evaluations || evaluations.length === 0) { + return ( +
+ Aucune évaluation pour cette période +
+ ); + } + + const startEdit = (ev, studentEval) => { + setEditingId(ev.id); + setEditScore(studentEval?.score ?? ''); + setEditComment(studentEval?.comment ?? ''); + setEditAbsent(studentEval?.is_absent ?? false); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditScore(''); + setEditComment(''); + setEditAbsent(false); + }; + + const handleSaveEdit = async (ev, studentEval) => { + if (onUpdateGrade && studentEval) { + await onUpdateGrade(studentEval.id, { + score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), + comment: editComment, + is_absent: editAbsent, + }); + } + cancelEdit(); + }; + + const handleDelete = async (studentEval) => { + if (onDeleteGrade && studentEval && confirm('Supprimer cette note ?')) { + await onDeleteGrade(studentEval.id); + } + }; + + // Grouper les évaluations par matière + const groupedBySpeciality = evaluations.reduce((acc, ev) => { + const key = ev.speciality_name || 'Sans matière'; + if (!acc[key]) { + acc[key] = { + name: key, + color: ev.speciality_color || '#6B7280', + evaluations: [], + totalScore: 0, + totalMaxScore: 0, + totalCoef: 0, + weightedSum: 0, + }; + } + + const studentEval = studentEvaluations.find( + (se) => se.evaluation === ev.id + ); + + const evalData = { + ...ev, + studentScore: studentEval?.score, + studentComment: studentEval?.comment, + isAbsent: studentEval?.is_absent, + }; + + acc[key].evaluations.push(evalData); + + // Calcul de la moyenne pondérée + if (studentEval?.score != null && !studentEval?.is_absent) { + const normalizedScore = (studentEval.score / ev.max_score) * 20; + acc[key].weightedSum += normalizedScore * ev.coefficient; + acc[key].totalCoef += parseFloat(ev.coefficient); + acc[key].totalScore += studentEval.score; + acc[key].totalMaxScore += parseFloat(ev.max_score); + } + + return acc; + }, {}); + + // Calcul de la moyenne générale + let totalWeightedSum = 0; + let totalCoef = 0; + Object.values(groupedBySpeciality).forEach((group) => { + if (group.totalCoef > 0) { + const groupAvg = group.weightedSum / group.totalCoef; + totalWeightedSum += groupAvg * group.totalCoef; + totalCoef += group.totalCoef; + } + }); + const generalAverage = totalCoef > 0 ? totalWeightedSum / totalCoef : null; + + const getScoreColor = (score, maxScore) => { + if (score == null) return 'text-gray-400'; + const percentage = (score / maxScore) * 100; + if (percentage >= 70) return 'text-green-600'; + if (percentage >= 50) return 'text-yellow-600'; + return 'text-red-600'; + }; + + const getAverageIcon = (avg) => { + if (avg >= 14) return ; + if (avg >= 10) return ; + return ; + }; + + return ( +
+ {/* Moyenne générale */} + {generalAverage !== null && ( +
+
+ + Moyenne générale +
+
+ {getAverageIcon(generalAverage)} + + {generalAverage.toFixed(2)}/20 + +
+
+ )} + + {/* Évaluations par matière */} + {Object.values(groupedBySpeciality).map((group) => { + const groupAverage = + group.totalCoef > 0 ? group.weightedSum / group.totalCoef : null; + + return ( +
+ {/* Header de la matière */} +
+
+ + {group.name} +
+ {groupAverage !== null && ( +
+ {getAverageIcon(groupAverage)} + + {groupAverage.toFixed(2)}/20 + +
+ )} +
+ + {/* Liste des évaluations */} +
+ {group.evaluations.map((ev) => { + const studentEval = studentEvaluations.find(se => se.evaluation === ev.id); + const isEditing = editingId === ev.id; + + return ( +
+
+
{ev.name}
+
+ {ev.date && ( + + {new Date(ev.date).toLocaleDateString('fr-FR')} + + )} + Coef: {ev.coefficient} +
+ {!isEditing && ev.studentComment && ( +
+ "{ev.studentComment}" +
+ )} + {isEditing && ( + setEditComment(e.target.value)} + placeholder="Commentaire" + className="mt-2 w-full text-sm px-2 py-1 border rounded" + /> + )} +
+
+ {isEditing ? ( + <> + + {!editAbsent && ( + setEditScore(e.target.value)} + min="0" + max={ev.max_score} + step="0.5" + className="w-16 text-center px-2 py-1 border rounded" + /> + )} + /{ev.max_score} + + + + ) : ( + <> + {ev.isAbsent ? ( + + Absent + + ) : ev.studentScore != null ? ( + + {ev.studentScore}/{ev.max_score} + + ) : ( + Non noté + )} + {editable && studentEval && ( + <> + + {onDeleteGrade && ( + + )} + + )} + + )} +
+
+ );})} +
+
+ ); + })} +
+ ); +} diff --git a/Front-End/src/components/Evaluation/index.js b/Front-End/src/components/Evaluation/index.js new file mode 100644 index 0000000..cf05e96 --- /dev/null +++ b/Front-End/src/components/Evaluation/index.js @@ -0,0 +1,4 @@ +export { default as EvaluationForm } from './EvaluationForm'; +export { default as EvaluationList } from './EvaluationList'; +export { default as EvaluationGradeTable } from './EvaluationGradeTable'; +export { default as EvaluationStudentView } from './EvaluationStudentView'; diff --git a/Front-End/src/utils/Url.js b/Front-End/src/utils/Url.js index 3064a15..d325afa 100644 --- a/Front-End/src/utils/Url.js +++ b/Front-End/src/utils/Url.js @@ -40,6 +40,8 @@ export const BE_SCHOOL_DISCOUNTS_URL = `${BASE_URL}/School/discounts`; export const BE_SCHOOL_PAYMENT_PLANS_URL = `${BASE_URL}/School/paymentPlans`; export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`; export const BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`; +export const BE_SCHOOL_EVALUATIONS_URL = `${BASE_URL}/School/evaluations`; +export const BE_SCHOOL_STUDENT_EVALUATIONS_URL = `${BASE_URL}/School/studentEvaluations`; // ESTABLISHMENT export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;