mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
Compare commits
3 Commits
4e50a0696f
...
N3WTS-6-Am
| Author | SHA1 | Date | |
|---|---|---|---|
| 905fa5dbfb | |||
| edb9ace6ae | |||
| 6fb3c5cdb4 |
@ -156,3 +156,46 @@ class EstablishmentCompetency(models.Model):
|
|||||||
if self.competency:
|
if self.competency:
|
||||||
return f"{self.establishment.name} - {self.competency.name}"
|
return f"{self.establishment.name} - {self.competency.name}"
|
||||||
return f"{self.establishment.name} - {self.custom_name} (custom)"
|
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}"
|
||||||
@ -10,7 +10,9 @@ from .models import (
|
|||||||
PaymentPlan,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
Competency
|
Competency,
|
||||||
|
Evaluation,
|
||||||
|
StudentEvaluation
|
||||||
)
|
)
|
||||||
from Auth.models import Profile, ProfileRole
|
from Auth.models import Profile, ProfileRole
|
||||||
from Subscriptions.models import Student
|
from Subscriptions.models import Student
|
||||||
@ -182,12 +184,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
|
|||||||
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
||||||
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
||||||
teachers_details = serializers.SerializerMethodField()
|
teachers_details = serializers.SerializerMethodField()
|
||||||
students = StudentDetailSerializer(many=True, read_only=True)
|
students = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SchoolClass
|
model = SchoolClass
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
def get_students(self, obj):
|
||||||
|
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
|
||||||
|
validated_students = obj.students.filter(registrationform__status=5)
|
||||||
|
return StudentDetailSerializer(validated_students, many=True).data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
teachers_data = validated_data.pop('teachers', [])
|
teachers_data = validated_data.pop('teachers', [])
|
||||||
levels_data = validated_data.pop('levels', [])
|
levels_data = validated_data.pop('levels', [])
|
||||||
@ -300,3 +307,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = PaymentMode
|
model = PaymentMode
|
||||||
fields = '__all__'
|
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}"
|
||||||
@ -11,6 +11,8 @@ from .views import (
|
|||||||
PaymentModeListCreateView, PaymentModeDetailView,
|
PaymentModeListCreateView, PaymentModeDetailView,
|
||||||
CompetencyListCreateView, CompetencyDetailView,
|
CompetencyListCreateView, CompetencyDetailView,
|
||||||
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
||||||
|
EvaluationListCreateView, EvaluationDetailView,
|
||||||
|
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -43,4 +45,13 @@ urlpatterns = [
|
|||||||
|
|
||||||
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
|
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
|
||||||
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
|
re_path(r'^establishmentCompetencies/(?P<id>[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<id>[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<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
|
||||||
]
|
]
|
||||||
@ -16,7 +16,9 @@ from .models import (
|
|||||||
PaymentPlan,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
Competency
|
Competency,
|
||||||
|
Evaluation,
|
||||||
|
StudentEvaluation
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TeacherSerializer,
|
TeacherSerializer,
|
||||||
@ -28,7 +30,9 @@ from .serializers import (
|
|||||||
PaymentPlanSerializer,
|
PaymentPlanSerializer,
|
||||||
PaymentModeSerializer,
|
PaymentModeSerializer,
|
||||||
EstablishmentCompetencySerializer,
|
EstablishmentCompetencySerializer,
|
||||||
CompetencySerializer
|
CompetencySerializer,
|
||||||
|
EvaluationSerializer,
|
||||||
|
StudentEvaluationSerializer
|
||||||
)
|
)
|
||||||
from Common.models import Domain, Category
|
from Common.models import Domain, Category
|
||||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||||
@ -785,3 +789,179 @@ class EstablishmentCompetencyDetailView(APIView):
|
|||||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||||
except EstablishmentCompetency.DoesNotExist:
|
except EstablishmentCompetency.DoesNotExist:
|
||||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
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)
|
||||||
|
|||||||
@ -13,8 +13,11 @@ def run_command(command):
|
|||||||
|
|
||||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
||||||
flush_data = os.getenv('flush_data', '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 = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||||
|
migrate_data=True
|
||||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||||
|
watch_mode=True
|
||||||
|
|
||||||
collect_static_cmd = [
|
collect_static_cmd = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"]
|
["python", "manage.py", "collectstatic", "--noinput"]
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import SelectChoice from '@/components/Form/SelectChoice';
|
|||||||
import Attendance from '@/components/Grades/Attendance';
|
import Attendance from '@/components/Grades/Attendance';
|
||||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
|
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
||||||
@ -15,9 +16,14 @@ import {
|
|||||||
editAbsences,
|
editAbsences,
|
||||||
deleteAbsences,
|
deleteAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import {
|
||||||
|
fetchEvaluations,
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
updateStudentEvaluation,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { Award, ArrowLeft } from 'lucide-react';
|
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
|
||||||
@ -46,6 +52,10 @@ export default function StudentGradesPage() {
|
|||||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
const [allAbsences, setAllAbsences] = useState([]);
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
|
||||||
|
// Evaluation states
|
||||||
|
const [evaluations, setEvaluations] = useState([]);
|
||||||
|
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||||
|
|
||||||
const getPeriods = () => {
|
const getPeriods = () => {
|
||||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||||
return [
|
return [
|
||||||
@ -135,6 +145,26 @@ export default function StudentGradesPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedEstablishmentId]);
|
}, [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(() => {
|
const absences = React.useMemo(() => {
|
||||||
return allAbsences
|
return allAbsences
|
||||||
.filter((a) => a.student === studentId)
|
.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 (
|
return (
|
||||||
<div className="p-4 md:p-8 space-y-6">
|
<div className="p-4 md:p-8 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -280,6 +322,22 @@ export default function StudentGradesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Évaluations par matière */}
|
||||||
|
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BookOpen className="w-6 h-6 text-emerald-600" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">
|
||||||
|
Évaluations par matière
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<EvaluationStudentView
|
||||||
|
evaluations={evaluations}
|
||||||
|
studentEvaluations={studentEvaluationsData}
|
||||||
|
editable={true}
|
||||||
|
onUpdateGrade={handleUpdateGrade}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 SectionHeader from '@/components/SectionHeader';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
@ -15,8 +15,10 @@ import {
|
|||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
fetchAbsences,
|
fetchAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluation } from '@/app/actions/schoolAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
function getPeriodString(periodValue, frequency) {
|
function getPeriodString(periodValue, frequency) {
|
||||||
@ -28,18 +30,22 @@ function getPeriodString(periodValue, frequency) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcPercent(data) {
|
function calcCompetencyStats(data) {
|
||||||
if (!data?.data) return null;
|
if (!data?.data) return null;
|
||||||
const scores = [];
|
const scores = [];
|
||||||
data.data.forEach((d) =>
|
data.data.forEach((d) =>
|
||||||
d.categories.forEach((c) =>
|
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;
|
if (!scores.length) return null;
|
||||||
return Math.round(
|
const total = scores.length;
|
||||||
(scores.filter((s) => s === 3).length / scores.length) * 100
|
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) {
|
function getPeriodColumns(frequency) {
|
||||||
@ -58,6 +64,13 @@ function getPeriodColumns(frequency) {
|
|||||||
return [];
|
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) {
|
function getCurrentPeriodValue(frequency) {
|
||||||
const periods =
|
const periods =
|
||||||
{
|
{
|
||||||
@ -81,18 +94,19 @@ function getCurrentPeriodValue(frequency) {
|
|||||||
return current?.value ?? null;
|
return current?.value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PercentBadge({ value, loading }) {
|
function PercentBadge({ value, loading, color }) {
|
||||||
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
||||||
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
||||||
const color =
|
const badgeColor = color || (
|
||||||
value >= 75
|
value >= 75
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: value >= 50
|
: value >= 50
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-red-100 text-red-600';
|
: 'bg-red-100 text-red-600'
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
|
||||||
>
|
>
|
||||||
{value}%
|
{value}%
|
||||||
</span>
|
</span>
|
||||||
@ -101,6 +115,7 @@ function PercentBadge({ value, loading }) {
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
@ -111,6 +126,12 @@ export default function Page() {
|
|||||||
const [statsMap, setStatsMap] = useState({});
|
const [statsMap, setStatsMap] = useState({});
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [absencesMap, setAbsencesMap] = useState({});
|
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(
|
const periodColumns = getPeriodColumns(
|
||||||
selectedEstablishmentEvaluationFrequency
|
selectedEstablishmentEvaluationFrequency
|
||||||
@ -138,7 +159,7 @@ export default function Page() {
|
|||||||
.catch((error) => logger.error('Error fetching absences:', error));
|
.catch((error) => logger.error('Error fetching absences:', error));
|
||||||
}, [selectedEstablishmentId]);
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
// Fetch stats for all students × all periods
|
// Fetch stats for all students - aggregate all periods
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
||||||
|
|
||||||
@ -156,15 +177,32 @@ export default function Page() {
|
|||||||
|
|
||||||
Promise.all(tasks).then((results) => {
|
Promise.all(tasks).then((results) => {
|
||||||
const map = {};
|
const map = {};
|
||||||
results.forEach(({ studentId, periodValue, data }) => {
|
// Group by student and aggregate all competency scores across periods
|
||||||
if (!map[studentId]) map[studentId] = {};
|
const studentScores = {};
|
||||||
map[studentId][periodValue] = calcPercent(data);
|
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) => {
|
// Calculate stats for each student
|
||||||
const vals = Object.values(map[id]).filter((v) => v !== null);
|
Object.keys(studentScores).forEach((studentId) => {
|
||||||
map[id].global = vals.length
|
const scores = studentScores[studentId];
|
||||||
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
if (!scores.length) {
|
||||||
: null;
|
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);
|
setStatsMap(map);
|
||||||
setStatsLoading(false);
|
setStatsLoading(false);
|
||||||
@ -189,6 +227,81 @@ export default function Page() {
|
|||||||
currentPage * ITEMS_PER_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) => {
|
const handleEvaluer = (e, studentId) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const periodStr = getPeriodString(
|
const periodStr = getPeriodString(
|
||||||
@ -205,8 +318,7 @@ export default function Page() {
|
|||||||
{ name: 'Élève', transform: () => null },
|
{ name: 'Élève', transform: () => null },
|
||||||
{ name: 'Niveau', transform: () => null },
|
{ name: 'Niveau', transform: () => null },
|
||||||
{ name: 'Classe', transform: () => null },
|
{ name: 'Classe', transform: () => null },
|
||||||
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
|
...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })),
|
||||||
{ name: 'Stat globale', transform: () => null },
|
|
||||||
{ name: 'Absences', transform: () => null },
|
{ name: 'Absences', transform: () => null },
|
||||||
{ name: 'Actions', transform: () => null },
|
{ name: 'Actions', transform: () => null },
|
||||||
];
|
];
|
||||||
@ -261,13 +373,6 @@ export default function Page() {
|
|||||||
) : (
|
) : (
|
||||||
student.associated_class_name
|
student.associated_class_name
|
||||||
);
|
);
|
||||||
case 'Stat globale':
|
|
||||||
return (
|
|
||||||
<PercentBadge
|
|
||||||
value={stats.global ?? null}
|
|
||||||
loading={statsLoading && !('global' in stats)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'Absences':
|
case 'Absences':
|
||||||
return absencesMap[student.id] ? (
|
return absencesMap[student.id] ? (
|
||||||
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
||||||
@ -287,6 +392,14 @@ export default function Page() {
|
|||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
Fiche
|
Fiche
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => openGradesModal(e, student)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition whitespace-nowrap"
|
||||||
|
title="Voir les notes"
|
||||||
|
>
|
||||||
|
<BarChart2 size={14} />
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleEvaluer(e, student.id)}
|
onClick={(e) => handleEvaluer(e, student.id)}
|
||||||
disabled={!currentPeriodValue}
|
disabled={!currentPeriodValue}
|
||||||
@ -299,12 +412,13 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default: {
|
default: {
|
||||||
const col = periodColumns.find((c) => c.label === column);
|
const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
|
||||||
if (col) {
|
if (col) {
|
||||||
return (
|
return (
|
||||||
<PercentBadge
|
<PercentBadge
|
||||||
value={stats[col.value] ?? null}
|
value={stats?.[col.key] ?? null}
|
||||||
loading={statsLoading && !(col.value in stats)}
|
loading={statsLoading && !stats}
|
||||||
|
color={col.color}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -346,6 +460,233 @@ export default function Page() {
|
|||||||
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
|
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modal Notes par matière */}
|
||||||
|
{gradesModalStudent && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">
|
||||||
|
Notes de {gradesModalStudent.first_name} {gradesModalStudent.last_name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{gradesModalStudent.associated_class_name || 'Classe non assignée'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={closeGradesModal}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full transition"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{gradesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
|
||||||
|
</div>
|
||||||
|
) : Object.keys(groupedBySubject).length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
Aucune note enregistrée pour cet élève.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 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 (
|
||||||
|
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-gray-600">Résumé</span>
|
||||||
|
{overallAvg !== null && (
|
||||||
|
<span className="text-lg font-bold text-emerald-700">
|
||||||
|
Moyenne générale : {overallAvg}/20
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{subjectAverages.map(({ subject, color, avg }) => (
|
||||||
|
<div
|
||||||
|
key={subject}
|
||||||
|
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-full border shadow-sm"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
></span>
|
||||||
|
<span className="text-sm text-gray-700">{subject}</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{avg.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div key={subject} className="border rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
style={{ backgroundColor: `${color}20` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
></span>
|
||||||
|
<span className="font-semibold text-gray-800">{subject}</span>
|
||||||
|
</div>
|
||||||
|
{avg !== null && (
|
||||||
|
<span className="text-sm font-bold text-gray-700">
|
||||||
|
Moyenne : {avg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{evaluations.map((evalItem) => {
|
||||||
|
const isEditing = editingEvalId === evalItem.id;
|
||||||
|
return (
|
||||||
|
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-700">
|
||||||
|
{evalItem.evaluation_name || 'Évaluation'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">
|
||||||
|
{evalItem.period || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editAbsent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditAbsent(e.target.checked);
|
||||||
|
if (e.target.checked) setEditScore('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Abs
|
||||||
|
</label>
|
||||||
|
{!editAbsent && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editScore}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">/{evalItem.max_score || 20}</span>
|
||||||
|
</div>
|
||||||
|
) : evalItem.is_absent ? (
|
||||||
|
<span className="text-orange-500 font-medium">Absent</span>
|
||||||
|
) : evalItem.score !== null ? (
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
{evalItem.score}/{evalItem.max_score || 20}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEval(evalItem)}
|
||||||
|
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
||||||
|
title="Enregistrer"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEditingEval}
|
||||||
|
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
|
||||||
|
title="Annuler"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => startEditingEval(evalItem)}
|
||||||
|
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteEval(evalItem)}
|
||||||
|
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={closeGradesModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
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 Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
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 { useSearchParams } from 'next/navigation';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -17,10 +17,12 @@ import {
|
|||||||
editAbsences,
|
editAbsences,
|
||||||
deleteAbsences,
|
deleteAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation';
|
||||||
|
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -38,8 +40,53 @@ export default function Page() {
|
|||||||
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
||||||
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
|
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 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
|
// AbsenceMoment constants
|
||||||
const AbsenceMoment = {
|
const AbsenceMoment = {
|
||||||
@ -158,6 +205,87 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredStudents, fetchedAbsences]);
|
}, [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) => {
|
const handleLevelClick = (label) => {
|
||||||
setSelectedLevels(
|
setSelectedLevels(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
@ -474,6 +602,41 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs Navigation */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('attendance')}
|
||||||
|
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
|
||||||
|
activeTab === 'attendance'
|
||||||
|
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
Appel du jour
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('evaluations')}
|
||||||
|
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
|
||||||
|
activeTab === 'evaluations'
|
||||||
|
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<ClipboardList className="w-5 h-5" />
|
||||||
|
Évaluations
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content: Attendance */}
|
||||||
|
{activeTab === 'attendance' && (
|
||||||
|
<>
|
||||||
{/* Affichage de la date du jour */}
|
{/* 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 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 space-x-3">
|
||||||
@ -728,6 +891,84 @@ export default function Page() {
|
|||||||
]}
|
]}
|
||||||
data={filteredStudents} // Utiliser les élèves filtrés
|
data={filteredStudents} // Utiliser les élèves filtrés
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content: Evaluations */}
|
||||||
|
{activeTab === 'evaluations' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header avec sélecteur de période et bouton d'ajout */}
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ClipboardList className="w-6 h-6 text-emerald-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">
|
||||||
|
Évaluations de la classe
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
<div className="w-48">
|
||||||
|
<SelectChoice
|
||||||
|
name="period"
|
||||||
|
placeHolder="Période"
|
||||||
|
choices={getPeriods()}
|
||||||
|
selected={selectedPeriod || ''}
|
||||||
|
callback={(e) => setSelectedPeriod(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
text="Nouvelle évaluation"
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={() => setShowEvaluationForm(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de création/édition d'évaluation */}
|
||||||
|
{showEvaluationForm && (
|
||||||
|
<EvaluationForm
|
||||||
|
specialities={specialities}
|
||||||
|
period={selectedPeriod}
|
||||||
|
schoolClassId={parseInt(schoolClassId)}
|
||||||
|
establishmentId={selectedEstablishmentId}
|
||||||
|
initialValues={editingEvaluation}
|
||||||
|
onSubmit={editingEvaluation ? handleUpdateEvaluation : handleCreateEvaluation}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowEvaluationForm(false);
|
||||||
|
setEditingEvaluation(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des évaluations */}
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||||
|
<EvaluationList
|
||||||
|
evaluations={evaluations}
|
||||||
|
onDelete={handleDeleteEvaluation}
|
||||||
|
onEdit={handleEditEvaluation}
|
||||||
|
onGradeStudents={(evaluation) => setSelectedEvaluation(evaluation)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de notation */}
|
||||||
|
{selectedEvaluation && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
|
||||||
|
<EvaluationGradeTable
|
||||||
|
evaluation={selectedEvaluation}
|
||||||
|
students={filteredStudents}
|
||||||
|
studentEvaluations={studentEvaluations}
|
||||||
|
onSave={handleSaveGrades}
|
||||||
|
onClose={() => setSelectedEvaluation(null)}
|
||||||
|
onDeleteGrade={handleDeleteGrade}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Popup */}
|
{/* Popup */}
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
BE_SCHOOL_PAYMENT_MODES_URL,
|
BE_SCHOOL_PAYMENT_MODES_URL,
|
||||||
BE_SCHOOL_ESTABLISHMENT_URL,
|
BE_SCHOOL_ESTABLISHMENT_URL,
|
||||||
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
||||||
|
BE_SCHOOL_EVALUATIONS_URL,
|
||||||
|
BE_SCHOOL_STUDENT_EVALUATIONS_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||||
|
|
||||||
@ -132,3 +134,71 @@ export const removeDatas = (url, id, csrfToken) => {
|
|||||||
headers: { 'X-CSRFToken': csrfToken },
|
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 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
158
Front-End/src/components/Evaluation/EvaluationForm.js
Normal file
158
Front-End/src/components/Evaluation/EvaluationForm.js
Normal file
@ -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 (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-4 p-4 bg-white rounded-lg border border-gray-200 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-lg text-gray-800">
|
||||||
|
{isEditing ? 'Modifier l\'évaluation' : 'Nouvelle évaluation'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
name="name"
|
||||||
|
label="Nom de l'évaluation"
|
||||||
|
placeholder="Ex: Contrôle de mathématiques"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
errorMsg={errors.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectChoice
|
||||||
|
name="speciality"
|
||||||
|
label="Matière"
|
||||||
|
placeHolder="Sélectionner une matière"
|
||||||
|
choices={specialities.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
|
selected={form.speciality}
|
||||||
|
callback={(e) => setForm({ ...form, speciality: e.target.value })}
|
||||||
|
errorMsg={errors.speciality}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
label="Date de l'évaluation"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<InputText
|
||||||
|
name="max_score"
|
||||||
|
type="number"
|
||||||
|
label="Note maximale"
|
||||||
|
value={form.max_score}
|
||||||
|
onChange={(e) => setForm({ ...form, max_score: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<InputText
|
||||||
|
name="coefficient"
|
||||||
|
type="number"
|
||||||
|
label="Coefficient"
|
||||||
|
value={form.coefficient}
|
||||||
|
onChange={(e) => setForm({ ...form, coefficient: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
name="description"
|
||||||
|
label="Description (optionnel)"
|
||||||
|
placeholder="Détails de l'évaluation..."
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
type="submit"
|
||||||
|
text={isEditing ? 'Enregistrer' : 'Créer l\'évaluation'}
|
||||||
|
icon={isEditing ? <Save size={16} /> : <Plus size={16} />}
|
||||||
|
/>
|
||||||
|
<Button type="button" text="Annuler" onClick={onCancel} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
Front-End/src/components/Evaluation/EvaluationGradeTable.js
Normal file
299
Front-End/src/components/Evaluation/EvaluationGradeTable.js
Normal file
@ -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 (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 shadow-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50 rounded-t-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg text-gray-800">
|
||||||
|
{evaluation.name}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-gray-500 flex gap-3">
|
||||||
|
<span>{evaluation.speciality_name}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Note max: {evaluation.max_score}</span>
|
||||||
|
{evaluation.date && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full"
|
||||||
|
>
|
||||||
|
<X size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto max-h-[60vh]">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||||
|
Élève
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-32">
|
||||||
|
Note / {evaluation.max_score}
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-24">
|
||||||
|
Absent
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||||
|
Commentaire
|
||||||
|
</th>
|
||||||
|
{onDeleteGrade && (
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-20">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{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 (
|
||||||
|
<tr
|
||||||
|
key={student.id}
|
||||||
|
className={`hover:bg-gray-50 ${isAbsent ? 'bg-red-50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-800">
|
||||||
|
{student.last_name} {student.first_name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max={evaluation.max_score}
|
||||||
|
value={studentGrade.score ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAbsentToggle(student.id)}
|
||||||
|
className={`p-2 rounded ${
|
||||||
|
isAbsent
|
||||||
|
? 'bg-red-100 text-red-600'
|
||||||
|
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title={isAbsent ? 'Marquer présent' : 'Marquer absent'}
|
||||||
|
>
|
||||||
|
<UserX size={18} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={studentGrade.comment ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{onDeleteGrade && (
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{existingEval && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Supprimer cette note ?')) {
|
||||||
|
onDeleteGrade(existingEval.id);
|
||||||
|
setGrades((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[student.id]: { score: '', comment: '', is_absent: false },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer la note"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer avec statistiques et boutons */}
|
||||||
|
<div className="p-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Statistiques */}
|
||||||
|
{stats && (
|
||||||
|
<div className="flex gap-4 text-sm text-gray-600">
|
||||||
|
<span>
|
||||||
|
Moyenne:{' '}
|
||||||
|
<span className="font-semibold text-emerald-600">
|
||||||
|
{stats.avg.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Min:{' '}
|
||||||
|
<span className="font-semibold text-red-600">{stats.min}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Max:{' '}
|
||||||
|
<span className="font-semibold text-green-600">{stats.max}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Notés: {stats.count}/{students.length}
|
||||||
|
</span>
|
||||||
|
{stats.absentCount > 0 && (
|
||||||
|
<span className="text-red-600">
|
||||||
|
Absents: {stats.absentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Boutons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button text="Fermer" onClick={onClose} />
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
text={isSaving ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
|
icon={<Save size={16} />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
Front-End/src/components/Evaluation/EvaluationList.js
Normal file
153
Front-End/src/components/Evaluation/EvaluationList.js
Normal file
@ -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 (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Aucune évaluation créée pour cette période
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.values(groupedBySpeciality).map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.name}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedId(expandedId === group.name ? null : group.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: group.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-gray-800">{group.name}</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
({group.evaluations.length} évaluation
|
||||||
|
{group.evaluations.length > 1 ? 's' : ''})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{expandedId === group.name ? (
|
||||||
|
<ChevronUp size={20} className="text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={20} className="text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedId === group.name && (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{group.evaluations.map((evaluation) => (
|
||||||
|
<div
|
||||||
|
key={evaluation.id}
|
||||||
|
className="p-3 flex items-center justify-between hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-700">
|
||||||
|
{evaluation.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 flex gap-3">
|
||||||
|
{evaluation.date && (
|
||||||
|
<span>
|
||||||
|
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Note max: {evaluation.max_score}</span>
|
||||||
|
<span>Coef: {evaluation.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
onClick={() => onGradeStudents(evaluation)}
|
||||||
|
icon={<ClipboardList size={16} />}
|
||||||
|
text="Noter"
|
||||||
|
title="Noter les élèves"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit && onEdit(evaluation)}
|
||||||
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(evaluation)}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
isOpen={deletePopupVisible}
|
||||||
|
message={`Êtes-vous sûr de vouloir supprimer l'évaluation "${evaluationToDelete?.name}" ?`}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeletePopupVisible(false);
|
||||||
|
setEvaluationToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
Front-End/src/components/Evaluation/EvaluationStudentView.js
Normal file
298
Front-End/src/components/Evaluation/EvaluationStudentView.js
Normal file
@ -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 (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
Aucune évaluation pour cette période
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <TrendingUp size={16} className="text-green-500" />;
|
||||||
|
if (avg >= 10) return <Minus size={16} className="text-yellow-500" />;
|
||||||
|
return <TrendingDown size={16} className="text-red-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Moyenne générale */}
|
||||||
|
{generalAverage !== null && (
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="text-emerald-600" size={24} />
|
||||||
|
<span className="font-medium text-emerald-800">Moyenne générale</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getAverageIcon(generalAverage)}
|
||||||
|
<span className="text-2xl font-bold text-emerald-700">
|
||||||
|
{generalAverage.toFixed(2)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Évaluations par matière */}
|
||||||
|
{Object.values(groupedBySpeciality).map((group) => {
|
||||||
|
const groupAverage =
|
||||||
|
group.totalCoef > 0 ? group.weightedSum / group.totalCoef : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.name}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header de la matière */}
|
||||||
|
<div
|
||||||
|
className="p-3 flex items-center justify-between"
|
||||||
|
style={{ backgroundColor: `${group.color}15` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: group.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-gray-800">{group.name}</span>
|
||||||
|
</div>
|
||||||
|
{groupAverage !== null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getAverageIcon(groupAverage)}
|
||||||
|
<span className="font-bold" style={{ color: group.color }}>
|
||||||
|
{groupAverage.toFixed(2)}/20
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des évaluations */}
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{group.evaluations.map((ev) => {
|
||||||
|
const studentEval = studentEvaluations.find(se => se.evaluation === ev.id);
|
||||||
|
const isEditing = editingId === ev.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="p-3 flex items-center justify-between hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-700">{ev.name}</div>
|
||||||
|
<div className="text-sm text-gray-500 flex gap-2">
|
||||||
|
{ev.date && (
|
||||||
|
<span>
|
||||||
|
{new Date(ev.date).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>Coef: {ev.coefficient}</span>
|
||||||
|
</div>
|
||||||
|
{!isEditing && ev.studentComment && (
|
||||||
|
<div className="text-sm text-gray-500 italic mt-1">
|
||||||
|
"{ev.studentComment}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editComment}
|
||||||
|
onChange={(e) => setEditComment(e.target.value)}
|
||||||
|
placeholder="Commentaire"
|
||||||
|
className="mt-2 w-full text-sm px-2 py-1 border rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editAbsent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditAbsent(e.target.checked);
|
||||||
|
if (e.target.checked) setEditScore('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Absent
|
||||||
|
</label>
|
||||||
|
{!editAbsent && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editScore}
|
||||||
|
onChange={(e) => setEditScore(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
max={ev.max_score}
|
||||||
|
step="0.5"
|
||||||
|
className="w-16 text-center px-2 py-1 border rounded"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">/{ev.max_score}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(ev, studentEval)}
|
||||||
|
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
||||||
|
title="Enregistrer"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
|
||||||
|
title="Annuler"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ev.isAbsent ? (
|
||||||
|
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm font-medium">
|
||||||
|
Absent
|
||||||
|
</span>
|
||||||
|
) : ev.studentScore != null ? (
|
||||||
|
<span
|
||||||
|
className={`text-lg font-bold ${getScoreColor(
|
||||||
|
ev.studentScore,
|
||||||
|
ev.max_score
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{ev.studentScore}/{ev.max_score}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Non noté</span>
|
||||||
|
)}
|
||||||
|
{editable && studentEval && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(ev, studentEval)}
|
||||||
|
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
{onDeleteGrade && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(studentEval)}
|
||||||
|
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
Front-End/src/components/Evaluation/index.js
Normal file
4
Front-End/src/components/Evaluation/index.js
Normal file
@ -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';
|
||||||
@ -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_PLANS_URL = `${BASE_URL}/School/paymentPlans`;
|
||||||
export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`;
|
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_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
|
// ESTABLISHMENT
|
||||||
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;
|
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;
|
||||||
|
|||||||
Reference in New Issue
Block a user