feat: Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6]

This commit is contained in:
N3WT DE COMPET
2026-04-03 22:10:32 +02:00
parent edb9ace6ae
commit 905fa5dbfb
15 changed files with 1970 additions and 79 deletions

View File

@ -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}"

View File

@ -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
@ -305,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}"

View File

@ -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"),
] ]

View File

@ -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)

View File

@ -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"]

View File

@ -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>
); );

View File

@ -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>
); );
} }

View File

@ -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

View File

@ -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 },
});
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&quot;{ev.studentComment}&quot;
</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>
);
}

View 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';

View File

@ -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`;