mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
Compare commits
5 Commits
7464b19de5
...
N3WTS-6-Am
| Author | SHA1 | Date | |
|---|---|---|---|
| 905fa5dbfb | |||
| edb9ace6ae | |||
| 4e50a0696f | |||
| 4248a589c5 | |||
| 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)
|
||||||
|
|||||||
@ -452,11 +452,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
|||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
guardians = GuardianByDICreationSerializer(many=True, required=False)
|
guardians = GuardianByDICreationSerializer(many=True, required=False)
|
||||||
associated_class_name = serializers.SerializerMethodField()
|
associated_class_name = serializers.SerializerMethodField()
|
||||||
|
associated_class_id = serializers.SerializerMethodField()
|
||||||
bilans = BilanCompetenceSerializer(many=True, read_only=True)
|
bilans = BilanCompetenceSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Student
|
model = Student
|
||||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
|
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'associated_class_id', 'photo', 'bilans']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
||||||
@ -466,6 +467,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
|||||||
def get_associated_class_name(self, obj):
|
def get_associated_class_name(self, obj):
|
||||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||||
|
|
||||||
|
def get_associated_class_id(self, obj):
|
||||||
|
return obj.associated_class.id if obj.associated_class else None
|
||||||
|
|
||||||
class NotificationSerializer(serializers.ModelSerializer):
|
class NotificationSerializer(serializers.ModelSerializer):
|
||||||
notification_type_label = serializers.ReadOnlyField()
|
notification_type_label = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
4
Front-End/public/icons/icon.svg
Normal file
4
Front-End/public/icons/icon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="80" fill="#10b981"/>
|
||||||
|
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
48
Front-End/public/sw.js
Normal file
48
Front-End/public/sw.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const CACHE_NAME = 'n3wt-school-v1';
|
||||||
|
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/favicon.svg',
|
||||||
|
'/favicon.ico',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Ne pas intercepter les requêtes API ou d'authentification
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith('/api/') ||
|
||||||
|
url.pathname.startsWith('/_next/') ||
|
||||||
|
event.request.method !== 'GET'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Mettre en cache les réponses réussies des ressources statiques
|
||||||
|
if (response.ok && url.origin === self.location.origin) {
|
||||||
|
const cloned = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
344
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
344
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
|
import Attendance from '@/components/Grades/Attendance';
|
||||||
|
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||||
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
|
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||||
|
import Button from '@/components/Form/Button';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
||||||
|
import {
|
||||||
|
fetchStudents,
|
||||||
|
fetchStudentCompetencies,
|
||||||
|
fetchAbsences,
|
||||||
|
editAbsences,
|
||||||
|
deleteAbsences,
|
||||||
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import {
|
||||||
|
fetchEvaluations,
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
updateStudentEvaluation,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
|
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
|
||||||
|
function getPeriodString(selectedPeriod, frequency) {
|
||||||
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
|
const nextYear = (year + 1).toString();
|
||||||
|
const schoolYear = `${year}-${nextYear}`;
|
||||||
|
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 3) return `A_${schoolYear}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentGradesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const studentId = Number(params.studentId);
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
|
useEstablishment();
|
||||||
|
const { getNiveauLabel } = useClasses();
|
||||||
|
|
||||||
|
const [student, setStudent] = useState(null);
|
||||||
|
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||||
|
const [grades, setGrades] = useState({});
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
|
||||||
|
// Evaluation states
|
||||||
|
const [evaluations, setEvaluations] = useState([]);
|
||||||
|
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||||
|
|
||||||
|
const getPeriods = () => {
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||||
|
return [
|
||||||
|
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||||
|
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||||
|
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||||
|
return [
|
||||||
|
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||||
|
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||||
|
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load student info
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
fetchStudents(selectedEstablishmentId, null, 5)
|
||||||
|
.then((students) => {
|
||||||
|
const found = students.find((s) => s.id === studentId);
|
||||||
|
setStudent(found || null);
|
||||||
|
})
|
||||||
|
.catch((error) => logger.error('Error fetching students:', error));
|
||||||
|
}
|
||||||
|
}, [selectedEstablishmentId, studentId]);
|
||||||
|
|
||||||
|
// Auto-select current period
|
||||||
|
useEffect(() => {
|
||||||
|
const periods = getPeriods();
|
||||||
|
const today = dayjs();
|
||||||
|
const current = periods.find((p) => {
|
||||||
|
const start = dayjs(`${today.year()}-${p.start}`);
|
||||||
|
const end = dayjs(`${today.year()}-${p.end}`);
|
||||||
|
return (
|
||||||
|
today.isAfter(start.subtract(1, 'day')) &&
|
||||||
|
today.isBefore(end.add(1, 'day'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setSelectedPeriod(current ? current.value : null);
|
||||||
|
}, [selectedEstablishmentEvaluationFrequency]);
|
||||||
|
|
||||||
|
// Load competencies
|
||||||
|
useEffect(() => {
|
||||||
|
if (studentId && selectedPeriod) {
|
||||||
|
const periodString = getPeriodString(
|
||||||
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
fetchStudentCompetencies(studentId, periodString)
|
||||||
|
.then((data) => {
|
||||||
|
setStudentCompetencies(data);
|
||||||
|
if (data && data.data) {
|
||||||
|
const initialGrades = {};
|
||||||
|
data.data.forEach((domaine) => {
|
||||||
|
domaine.categories.forEach((cat) => {
|
||||||
|
cat.competences.forEach((comp) => {
|
||||||
|
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setGrades(initialGrades);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('Error fetching studentCompetencies:', error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setGrades({});
|
||||||
|
setStudentCompetencies(null);
|
||||||
|
}
|
||||||
|
}, [studentId, selectedPeriod]);
|
||||||
|
|
||||||
|
// Load absences
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
fetchAbsences(selectedEstablishmentId)
|
||||||
|
.then((data) => setAllAbsences(data))
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('Erreur lors du fetch des absences:', error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
|
// Load evaluations for the student
|
||||||
|
useEffect(() => {
|
||||||
|
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) {
|
||||||
|
const periodString = getPeriodString(
|
||||||
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load evaluations for the class
|
||||||
|
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString)
|
||||||
|
.then((data) => setEvaluations(data))
|
||||||
|
.catch((error) => logger.error('Erreur lors du fetch des évaluations:', error));
|
||||||
|
|
||||||
|
// Load student's evaluation scores
|
||||||
|
fetchStudentEvaluations(studentId, null, periodString, null)
|
||||||
|
.then((data) => setStudentEvaluationsData(data))
|
||||||
|
.catch((error) => logger.error('Erreur lors du fetch des notes:', error));
|
||||||
|
}
|
||||||
|
}, [student, selectedPeriod, selectedEstablishmentId]);
|
||||||
|
|
||||||
|
const absences = React.useMemo(() => {
|
||||||
|
return allAbsences
|
||||||
|
.filter((a) => a.student === studentId)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
date: a.day,
|
||||||
|
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||||
|
reason: a.reason,
|
||||||
|
justified: [1, 3].includes(a.reason),
|
||||||
|
moment: a.moment,
|
||||||
|
commentaire: a.commentaire,
|
||||||
|
}));
|
||||||
|
}, [allAbsences, studentId]);
|
||||||
|
|
||||||
|
const handleToggleJustify = (absence) => {
|
||||||
|
const newReason =
|
||||||
|
absence.type === 'Absence'
|
||||||
|
? absence.justified ? 2 : 1
|
||||||
|
: absence.justified ? 4 : 3;
|
||||||
|
|
||||||
|
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => logger.error('Erreur lors du changement de justification', e));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAbsence = (absence) => {
|
||||||
|
return deleteAbsences(absence.id, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Erreur lors de la suppression de l'absence", e)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-4 md:p-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/admin/grades')}
|
||||||
|
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||||
|
aria-label="Retour à la liste"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student profile */}
|
||||||
|
{student && (
|
||||||
|
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||||
|
{student.photo ? (
|
||||||
|
<img
|
||||||
|
src={`${BASE_URL}${student.photo}`}
|
||||||
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
|
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-emerald-100">
|
||||||
|
{student.first_name?.[0]}
|
||||||
|
{student.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 text-center sm:text-left">
|
||||||
|
<div className="text-xl font-bold text-emerald-800">
|
||||||
|
{student.last_name} {student.first_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-1">
|
||||||
|
Niveau :{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{getNiveauLabel(student.level)}
|
||||||
|
</span>
|
||||||
|
{' | '}
|
||||||
|
Classe :{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{student.associated_class_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period selector + Evaluate button */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<SelectChoice
|
||||||
|
name="period"
|
||||||
|
label="Période"
|
||||||
|
placeHolder="Choisir la période"
|
||||||
|
choices={getPeriods().map((period) => {
|
||||||
|
const today = dayjs();
|
||||||
|
const end = dayjs(`${today.year()}-${period.end}`);
|
||||||
|
return {
|
||||||
|
value: period.value,
|
||||||
|
label: period.label,
|
||||||
|
disabled: today.isAfter(end),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
selected={selectedPeriod || ''}
|
||||||
|
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
onClick={() => {
|
||||||
|
const periodString = getPeriodString(
|
||||||
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
router.push(
|
||||||
|
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto"
|
||||||
|
icon={<Award className="w-5 h-5" />}
|
||||||
|
text="Évaluer"
|
||||||
|
title="Évaluer l'élève"
|
||||||
|
disabled={!selectedPeriod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats + Absences */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Attendance
|
||||||
|
absences={absences}
|
||||||
|
onToggleJustify={handleToggleJustify}
|
||||||
|
onDelete={handleDeleteAbsence}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<GradesStatsCircle grades={grades} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -8,8 +8,8 @@ import {
|
|||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
editStudentCompetencies,
|
editStudentCompetencies,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import { Award, ArrowLeft } from 'lucide-react';
|
||||||
import { Award } from 'lucide-react';
|
import logger from '@/utils/logger';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
|
|||||||
'success',
|
'success',
|
||||||
'Succès'
|
'Succès'
|
||||||
);
|
);
|
||||||
router.back();
|
router.push(`/admin/grades/${studentId}`);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
showNotification(
|
showNotification(
|
||||||
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-4">
|
<div className="h-full flex flex-col p-4">
|
||||||
<SectionHeader
|
<div className="flex items-center gap-3 mb-4">
|
||||||
icon={Award}
|
<button
|
||||||
title="Bilan de compétence"
|
onClick={() => router.push('/admin/grades')}
|
||||||
description="Evaluez les compétence de l'élève"
|
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||||
/>
|
aria-label="Retour à la fiche élève"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Bilan de compétence</h1>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<form
|
<form
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button
|
|
||||||
text="Retour"
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
||||||
/>
|
|
||||||
<Button text="Enregistrer" primary type="submit" />
|
<Button text="Enregistrer" primary type="submit" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
import { disconnect } from '@/app/actions/authAction';
|
import { disconnect } from '@/app/actions/authAction';
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import MobileTopbar from '@/components/MobileTopbar';
|
||||||
import { RIGHTS } from '@/utils/rights';
|
import { RIGHTS } from '@/utils/rights';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
|
||||||
@ -123,9 +124,12 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||||
|
{/* Topbar mobile (hamburger + logo) */}
|
||||||
|
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -146,7 +150,7 @@ export default function Layout({ children }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main container */}
|
{/* Main container */}
|
||||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
|
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -163,7 +163,7 @@ export default function DashboardPage() {
|
|||||||
if (isLoading) return <Loader />;
|
if (isLoading) return <Loader />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={selectedEstablishmentId} className="p-6">
|
<div key={selectedEstablishmentId} className="p-4 md:p-6">
|
||||||
{/* Statistiques principales */}
|
{/* Statistiques principales */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -200,12 +200,12 @@ export default function DashboardPage() {
|
|||||||
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Graphique des inscriptions */}
|
{/* Graphique des inscriptions */}
|
||||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||||
<h2 className="text-lg font-semibold mb-6">
|
<h2 className="text-lg font-semibold mb-4 md:mb-6">
|
||||||
{t('inscriptionTrends')}
|
{t('inscriptionTrends')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-6 mt-4">
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1">
|
||||||
<LineChart data={monthlyRegistrations} />
|
<LineChart data={monthlyRegistrations} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
@ -214,13 +214,13 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Présence et assiduité */}
|
{/* Présence et assiduité */}
|
||||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||||
<Attendance absences={absencesToday} readOnly={true} />
|
<Attendance absences={absencesToday} readOnly={true} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Colonne de droite : Événements à venir */}
|
{/* Colonne de droite : Événements à venir */}
|
||||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||||
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||||
{upcomingEvents.map((event, index) => (
|
{upcomingEvents.map((event, index) => (
|
||||||
<EventCard key={index} {...event} />
|
<EventCard key={index} {...event} />
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useEstablishment } from '@/context/EstablishmentContext';
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [eventData, setEventData] = useState({
|
const [eventData, setEventData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -56,13 +57,17 @@ export default function Page() {
|
|||||||
modeSet={PlanningModes.PLANNING}
|
modeSet={PlanningModes.PLANNING}
|
||||||
>
|
>
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
<ScheduleNavigation />
|
<ScheduleNavigation
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
|
/>
|
||||||
<Calendar
|
<Calendar
|
||||||
onDateClick={initializeNewEvent}
|
onDateClick={initializeNewEvent}
|
||||||
onEventClick={(event) => {
|
onEventClick={(event) => {
|
||||||
setEventData(event);
|
setEventData(event);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onOpenDrawer={() => setIsDrawerOpen(true)}
|
||||||
/>
|
/>
|
||||||
<EventModal
|
<EventModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -71,6 +71,8 @@ export default function CreateSubscriptionPage() {
|
|||||||
const registerFormMoment = searchParams.get('school_year');
|
const registerFormMoment = searchParams.get('school_year');
|
||||||
|
|
||||||
const [students, setStudents] = useState([]);
|
const [students, setStudents] = useState([]);
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [studentsPage, setStudentsPage] = useState(1);
|
||||||
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
||||||
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
||||||
const [registrationFees, setRegistrationFees] = useState([]);
|
const [registrationFees, setRegistrationFees] = useState([]);
|
||||||
@ -179,6 +181,8 @@ export default function CreateSubscriptionPage() {
|
|||||||
formDataRef.current = formData;
|
formDataRef.current = formData;
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
|
useEffect(() => { setStudentsPage(1); }, [students]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formData.guardianEmail) {
|
if (!formData.guardianEmail) {
|
||||||
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
||||||
@ -709,6 +713,9 @@ export default function CreateSubscriptionPage() {
|
|||||||
return finalAmount.toFixed(2);
|
return finalAmount.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
if (isLoading === true) {
|
if (isLoading === true) {
|
||||||
return <Loader />; // Affichez le composant Loader
|
return <Loader />; // Affichez le composant Loader
|
||||||
}
|
}
|
||||||
@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
{!isNewResponsable && (
|
{!isNewResponsable && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Table
|
<Table
|
||||||
data={students}
|
data={pagedStudents}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
name: 'photo',
|
name: 'photo',
|
||||||
@ -927,6 +934,10 @@ export default function CreateSubscriptionPage() {
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={studentsPage}
|
||||||
|
totalPages={studentsTotalPages}
|
||||||
|
onPageChange={setStudentsPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedStudent && (
|
{selectedStudent && (
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
|
import { MessageSquare, Settings, Home } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
FE_PARENTS_HOME_URL,
|
FE_PARENTS_HOME_URL,
|
||||||
FE_PARENTS_MESSAGERIE_URL
|
FE_PARENTS_MESSAGERIE_URL
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
import { disconnect } from '@/app/actions/authAction';
|
import { disconnect } from '@/app/actions/authAction';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
|
import MobileTopbar from '@/components/MobileTopbar';
|
||||||
import { RIGHTS } from '@/utils/rights';
|
import { RIGHTS } from '@/utils/rights';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
@ -73,17 +74,12 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
||||||
{/* Bouton hamburger pour mobile */}
|
{/* Topbar mobile (hamburger + logo) */}
|
||||||
<button
|
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||||
onClick={toggleSidebar}
|
|
||||||
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
|
|
||||||
>
|
|
||||||
<Menu size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -104,7 +100,7 @@ export default function Layout({ children }) {
|
|||||||
|
|
||||||
{/* Main container */}
|
{/* Main container */}
|
||||||
<div
|
<div
|
||||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,12 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import Providers from '@/components/Providers';
|
import Providers from '@/components/Providers';
|
||||||
|
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
|
||||||
import '@/css/tailwind.css';
|
import '@/css/tailwind.css';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'N3WT-SCHOOL',
|
title: 'N3WT-SCHOOL',
|
||||||
description: "Gestion de l'école",
|
description: "Gestion de l'école",
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
title: 'N3WT School',
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
@ -14,10 +21,11 @@ export const metadata = {
|
|||||||
type: 'image/svg+xml',
|
type: 'image/svg+xml',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '/favicon.ico', // Fallback pour les anciens navigateurs
|
url: '/favicon.ico',
|
||||||
sizes: 'any',
|
sizes: 'any',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
apple: '/icons/icon.svg',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -32,6 +40,7 @@ export default async function RootLayout({ children, params }) {
|
|||||||
<Providers messages={messages} locale={locale} session={params.session}>
|
<Providers messages={messages} locale={locale} session={params.session}>
|
||||||
{children}
|
{children}
|
||||||
</Providers>
|
</Providers>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
26
Front-End/src/app/manifest.js
Normal file
26
Front-End/src/app/manifest.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export default function manifest() {
|
||||||
|
return {
|
||||||
|
name: 'N3WT School',
|
||||||
|
short_name: 'N3WT School',
|
||||||
|
description: "Gestion de l'école",
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#f0fdf4',
|
||||||
|
theme_color: '#10b981',
|
||||||
|
orientation: 'portrait',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icons/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icons/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import WeekView from '@/components/Calendar/WeekView';
|
|||||||
import MonthView from '@/components/Calendar/MonthView';
|
import MonthView from '@/components/Calendar/MonthView';
|
||||||
import YearView from '@/components/Calendar/YearView';
|
import YearView from '@/components/Calendar/YearView';
|
||||||
import PlanningView from '@/components/Calendar/PlanningView';
|
import PlanningView from '@/components/Calendar/PlanningView';
|
||||||
|
import DayView from '@/components/Calendar/DayView';
|
||||||
import ToggleView from '@/components/ToggleView';
|
import ToggleView from '@/components/ToggleView';
|
||||||
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@ -11,9 +12,11 @@ import {
|
|||||||
addWeeks,
|
addWeeks,
|
||||||
addMonths,
|
addMonths,
|
||||||
addYears,
|
addYears,
|
||||||
|
addDays,
|
||||||
subWeeks,
|
subWeeks,
|
||||||
subMonths,
|
subMonths,
|
||||||
subYears,
|
subYears,
|
||||||
|
subDays,
|
||||||
getWeek,
|
getWeek,
|
||||||
setMonth,
|
setMonth,
|
||||||
setYear,
|
setYear,
|
||||||
@ -22,7 +25,7 @@ import { fr } from 'date-fns/locale';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
|
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => {
|
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '', onOpenDrawer = () => {} }) => {
|
||||||
const {
|
const {
|
||||||
currentDate,
|
currentDate,
|
||||||
setCurrentDate,
|
setCurrentDate,
|
||||||
@ -35,6 +38,14 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
} = usePlanning();
|
} = usePlanning();
|
||||||
const [visibleEvents, setVisibleEvents] = useState([]);
|
const [visibleEvents, setVisibleEvents] = useState([]);
|
||||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobile(window.innerWidth < 768);
|
||||||
|
check();
|
||||||
|
window.addEventListener('resize', check);
|
||||||
|
return () => window.removeEventListener('resize', check);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Ajouter ces fonctions pour la gestion des mois et années
|
// Ajouter ces fonctions pour la gestion des mois et années
|
||||||
const months = Array.from({ length: 12 }, (_, i) => ({
|
const months = Array.from({ length: 12 }, (_, i) => ({
|
||||||
@ -68,7 +79,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
|
|
||||||
const navigateDate = (direction) => {
|
const navigateDate = (direction) => {
|
||||||
const getNewDate = () => {
|
const getNewDate = () => {
|
||||||
switch (viewType) {
|
const effectiveView = isMobile ? 'day' : viewType;
|
||||||
|
switch (effectiveView) {
|
||||||
|
case 'day':
|
||||||
|
return direction === 'next'
|
||||||
|
? addDays(currentDate, 1)
|
||||||
|
: subDays(currentDate, 1);
|
||||||
case 'week':
|
case 'week':
|
||||||
return direction === 'next'
|
return direction === 'next'
|
||||||
? addWeeks(currentDate, 1)
|
? addWeeks(currentDate, 1)
|
||||||
@ -91,8 +107,9 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
{/* Header uniquement sur desktop */}
|
||||||
{/* Navigation à gauche */}
|
<div className="hidden md:flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
||||||
|
<>
|
||||||
{planningMode === PlanningModes.PLANNING && (
|
{planningMode === PlanningModes.PLANNING && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@ -101,10 +118,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
>
|
>
|
||||||
Aujourd'hui
|
Aujourd'hui
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||||
onClick={() => navigateDate('prev')}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-full"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -113,11 +127,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
|
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
{format(
|
{format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
|
||||||
currentDate,
|
|
||||||
viewType === 'year' ? 'yyyy' : 'MMMM yyyy',
|
|
||||||
{ locale: fr }
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -127,11 +137,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{months.map((month) => (
|
{months.map((month) => (
|
||||||
<button
|
<button key={month.value} onClick={() => handleMonthSelect(month.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||||
key={month.value}
|
|
||||||
onClick={() => handleMonthSelect(month.value)}
|
|
||||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
|
||||||
>
|
|
||||||
{month.label}
|
{month.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -141,11 +147,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{years.map((year) => (
|
{years.map((year) => (
|
||||||
<button
|
<button key={year.value} onClick={() => handleYearSelect(year.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||||
key={year.value}
|
|
||||||
onClick={() => handleYearSelect(year.value)}
|
|
||||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
|
||||||
>
|
|
||||||
{year.label}
|
{year.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -154,16 +156,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||||
onClick={() => navigateDate('next')}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-full"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Centre : numéro de semaine ou classe/niveau */}
|
|
||||||
<div className="flex-1 flex justify-center">
|
<div className="flex-1 flex justify-center">
|
||||||
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
|
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
|
||||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
|
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
|
||||||
@ -175,13 +173,11 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
)}
|
)}
|
||||||
{parentView && (
|
{parentView && (
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
|
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
|
||||||
{/* À adapter selon les props disponibles */}
|
|
||||||
{planningClassName}
|
{planningClassName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contrôles à droite */}
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{planningMode === PlanningModes.PLANNING && (
|
{planningMode === PlanningModes.PLANNING && (
|
||||||
<ToggleView viewType={viewType} setViewType={setViewType} />
|
<ToggleView viewType={viewType} setViewType={setViewType} />
|
||||||
@ -195,12 +191,30 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contenu scrollable */}
|
{/* Contenu scrollable */}
|
||||||
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
|
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{viewType === 'week' && (
|
{isMobile && (
|
||||||
|
<motion.div
|
||||||
|
key="day"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="h-full flex flex-col"
|
||||||
|
>
|
||||||
|
<DayView
|
||||||
|
onDateClick={onDateClick}
|
||||||
|
onEventClick={onEventClick}
|
||||||
|
events={visibleEvents}
|
||||||
|
onOpenDrawer={onOpenDrawer}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{!isMobile && viewType === 'week' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="week"
|
key="week"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -216,7 +230,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{viewType === 'month' && (
|
{!isMobile && viewType === 'month' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="month"
|
key="month"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -231,7 +245,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{viewType === 'year' && (
|
{!isMobile && viewType === 'year' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="year"
|
key="year"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -242,7 +256,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
|||||||
<YearView onDateClick={onDateClick} events={visibleEvents} />
|
<YearView onDateClick={onDateClick} events={visibleEvents} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{viewType === 'planning' && (
|
{!isMobile && viewType === 'planning' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="planning"
|
key="planning"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|||||||
230
Front-End/src/components/Calendar/DayView.js
Normal file
230
Front-End/src/components/Calendar/DayView.js
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
import { usePlanning } from '@/context/PlanningContext';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfWeek,
|
||||||
|
addDays,
|
||||||
|
subDays,
|
||||||
|
isSameDay,
|
||||||
|
isToday,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { fr } from 'date-fns/locale';
|
||||||
|
import { getWeekEvents } from '@/utils/events';
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
|
||||||
|
const { currentDate, setCurrentDate, parentView } = usePlanning();
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
|
||||||
|
const timeSlots = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
const isCurrentDay = isSameDay(currentDate, new Date());
|
||||||
|
const dayEvents = getWeekEvents(currentDate, events) || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setCurrentTime(new Date()), 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current && isCurrentDay) {
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollRef.current.scrollTop = currentHour * 80 - 200;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [currentDate, isCurrentDay]);
|
||||||
|
|
||||||
|
const getCurrentTimePosition = () => {
|
||||||
|
const hours = currentTime.getHours();
|
||||||
|
const minutes = currentTime.getMinutes();
|
||||||
|
return `${(hours + minutes / 60) * 5}rem`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateEventStyle = (event, allDayEvents) => {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
const end = new Date(event.end);
|
||||||
|
const startMinutes = (start.getMinutes() / 60) * 5;
|
||||||
|
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
|
||||||
|
|
||||||
|
const overlapping = allDayEvents.filter((other) => {
|
||||||
|
if (other.id === event.id) return false;
|
||||||
|
const oStart = new Date(other.start);
|
||||||
|
const oEnd = new Date(other.end);
|
||||||
|
return !(oEnd <= start || oStart >= end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventIndex = overlapping.findIndex((e) => e.id > event.id) + 1;
|
||||||
|
const total = overlapping.length + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: `${Math.max(duration, 1.5)}rem`,
|
||||||
|
position: 'absolute',
|
||||||
|
width: `calc((100% / ${total}) - 4px)`,
|
||||||
|
left: `calc((100% / ${total}) * ${eventIndex})`,
|
||||||
|
backgroundColor: `${event.color}15`,
|
||||||
|
borderLeft: `3px solid ${event.color}`,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
zIndex: 1,
|
||||||
|
transform: `translateY(${startMinutes}rem)`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Barre de navigation (remplace le header Calendar sur mobile) */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-white border-b shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onOpenDrawer}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full"
|
||||||
|
aria-label="Ouvrir les plannings"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentDate(subDays(currentDate, 1))}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<label className="relative cursor-pointer">
|
||||||
|
<span className="px-2 py-1 text-sm font-semibold text-gray-800 hover:bg-gray-100 rounded-md capitalize">
|
||||||
|
{format(currentDate, 'EEE d MMM', { locale: fr })}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
||||||
|
value={format(currentDate, 'yyyy-MM-dd')}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) setCurrentDate(new Date(e.target.value + 'T12:00:00'));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentDate(addDays(currentDate, 1))}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onDateClick?.(currentDate)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bandeau jours de la semaine */}
|
||||||
|
<div className="flex gap-1 px-2 py-2 bg-white border-b overflow-x-auto shrink-0">
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.toISOString()}
|
||||||
|
onClick={() => setCurrentDate(day)}
|
||||||
|
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
|
||||||
|
isSameDay(day, currentDate)
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: isToday(day)
|
||||||
|
? 'border border-emerald-400 text-emerald-600'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium uppercase">
|
||||||
|
{format(day, 'EEE', { locale: fr })}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{format(day, 'd')}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grille horaire */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
|
||||||
|
{isCurrentDay && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
|
||||||
|
style={{ top: getCurrentTimePosition() }}
|
||||||
|
>
|
||||||
|
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid w-full bg-gray-100 gap-[1px]"
|
||||||
|
style={{ gridTemplateColumns: '2.5rem 1fr' }}
|
||||||
|
>
|
||||||
|
{timeSlots.map((hour) => (
|
||||||
|
<React.Fragment key={hour}>
|
||||||
|
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
|
||||||
|
{`${hour.toString().padStart(2, '0')}:00`}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`h-20 relative border-b border-gray-100 ${
|
||||||
|
isCurrentDay ? 'bg-emerald-50/30' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={
|
||||||
|
parentView
|
||||||
|
? undefined
|
||||||
|
: () => {
|
||||||
|
const date = new Date(currentDate);
|
||||||
|
date.setHours(hour);
|
||||||
|
date.setMinutes(0);
|
||||||
|
onDateClick(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dayEvents
|
||||||
|
.filter((e) => new Date(e.start).getHours() === hour)
|
||||||
|
.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
|
||||||
|
style={calculateEventStyle(event, dayEvents)}
|
||||||
|
onClick={
|
||||||
|
parentView
|
||||||
|
? undefined
|
||||||
|
: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEventClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-1">
|
||||||
|
<div
|
||||||
|
className="font-semibold text-xs truncate"
|
||||||
|
style={{ color: event.color }}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: event.color, opacity: 0.75 }}
|
||||||
|
>
|
||||||
|
{format(new Date(event.start), 'HH:mm')} –{' '}
|
||||||
|
{format(new Date(event.end), 'HH:mm')}
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div
|
||||||
|
className="text-xs truncate"
|
||||||
|
style={{ color: event.color, opacity: 0.75 }}
|
||||||
|
>
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DayView;
|
||||||
@ -253,7 +253,7 @@ export default function EventModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dates */}
|
{/* Dates */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Début
|
Début
|
||||||
|
|||||||
@ -75,24 +75,37 @@ const MonthView = ({ onDateClick, onEventClick }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dayLabels = [
|
||||||
|
{ short: 'L', long: 'Lun' },
|
||||||
|
{ short: 'M', long: 'Mar' },
|
||||||
|
{ short: 'M', long: 'Mer' },
|
||||||
|
{ short: 'J', long: 'Jeu' },
|
||||||
|
{ short: 'V', long: 'Ven' },
|
||||||
|
{ short: 'S', long: 'Sam' },
|
||||||
|
{ short: 'D', long: 'Dim' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white">
|
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white overflow-x-auto">
|
||||||
|
<div className="min-w-[280px]">
|
||||||
{/* En-tête des jours de la semaine */}
|
{/* En-tête des jours de la semaine */}
|
||||||
<div className="grid grid-cols-7 border-b">
|
<div className="grid grid-cols-7 border-b">
|
||||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => (
|
{dayLabels.map((day, i) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={i}
|
||||||
className="p-2 text-center text-sm font-medium text-gray-500"
|
className="p-1 sm:p-2 text-center text-xs sm:text-sm font-medium text-gray-500"
|
||||||
>
|
>
|
||||||
{day}
|
<span className="sm:hidden">{day.short}</span>
|
||||||
|
<span className="hidden sm:inline">{day.long}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Grille des jours */}
|
{/* Grille des jours */}
|
||||||
<div className="flex-1 grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
<div className="grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
||||||
{days.map((day) => renderDay(day))}
|
{days.map((day) => renderDay(day))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const PlanningView = ({ events, onEventClick }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white h-full overflow-auto">
|
<div className="bg-white h-full overflow-auto">
|
||||||
<table className="w-full border-collapse">
|
<table className="min-w-full border-collapse">
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
|
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { usePlanning, PlanningModes } from '@/context/PlanningContext';
|
|||||||
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
|
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen = false, onClose = () => {} }) {
|
||||||
const {
|
const {
|
||||||
schedules,
|
schedules,
|
||||||
selectedSchedule,
|
selectedSchedule,
|
||||||
@ -62,22 +62,10 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const title = planningMode === PlanningModes.CLASS_SCHEDULE ? 'Emplois du temps' : 'Plannings';
|
||||||
<nav className="w-64 border-r p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="font-semibold">
|
|
||||||
{planningMode === PlanningModes.CLASS_SCHEDULE
|
|
||||||
? 'Emplois du temps'
|
|
||||||
: 'Plannings'}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsAddingNew(true)}
|
|
||||||
className="p-1 hover:bg-gray-100 rounded"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
const listContent = (
|
||||||
|
<>
|
||||||
{isAddingNew && (
|
{isAddingNew && (
|
||||||
<div className="mb-4 p-2 border rounded">
|
<div className="mb-4 p-2 border rounded">
|
||||||
<input
|
<input
|
||||||
@ -251,6 +239,50 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop : sidebar fixe */}
|
||||||
|
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{listContent}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile : drawer en overlay */}
|
||||||
|
<div
|
||||||
|
className={`md:hidden fixed inset-0 z-50 transition-opacity duration-200 ${
|
||||||
|
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={`absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl flex flex-col transition-transform duration-200 ${
|
||||||
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||||
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{listContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-4 p-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||||
{months.map((month) => (
|
{months.map((month) => (
|
||||||
<MonthCard
|
<MonthCard
|
||||||
key={month.getTime()}
|
key={month.getTime()}
|
||||||
|
|||||||
@ -15,27 +15,28 @@ export default function LineChart({ data }) {
|
|||||||
.filter((idx) => idx !== -1);
|
.filter((idx) => idx !== -1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full flex space-x-4">
|
||||||
className="w-full flex items-end space-x-4"
|
|
||||||
style={{ height: chartHeight }}
|
|
||||||
>
|
|
||||||
{data.map((point, idx) => {
|
{data.map((point, idx) => {
|
||||||
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px
|
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8);
|
||||||
const isMax = maxIndices.includes(idx);
|
const isMax = maxIndices.includes(idx);
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex flex-col items-center flex-1">
|
<div key={idx} className="flex flex-col items-center flex-1">
|
||||||
{/* Valeur au-dessus de la barre */}
|
{/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */}
|
||||||
<span className="text-xs mb-1 text-gray-700 font-semibold">
|
<span className="text-xs mb-1 text-gray-700 font-semibold">
|
||||||
{point.value}
|
{point.value}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Zone barres à hauteur fixe, alignées en bas */}
|
||||||
|
<div
|
||||||
|
className="w-full flex items-end justify-center"
|
||||||
|
style={{ height: chartHeight }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
|
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
|
||||||
style={{
|
style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
|
||||||
height: `${barHeight}px`,
|
|
||||||
transition: 'height 0.3s',
|
|
||||||
}}
|
|
||||||
title={`${point.month}: ${point.value}`}
|
title={`${point.month}: ${point.value}`}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Label mois en dessous */}
|
||||||
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
|
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
import { MessageSquare, Plus, Search, Trash2, ArrowLeft } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
@ -99,6 +99,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
|||||||
// États pour la confirmation de suppression
|
// États pour la confirmation de suppression
|
||||||
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
|
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
|
||||||
const [conversationToDelete, setConversationToDelete] = useState(null);
|
const [conversationToDelete, setConversationToDelete] = useState(null);
|
||||||
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(true);
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
@ -541,6 +542,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
|||||||
logger.debug('🔄 Sélection de la conversation:', conversation);
|
logger.debug('🔄 Sélection de la conversation:', conversation);
|
||||||
setSelectedConversation(conversation);
|
setSelectedConversation(conversation);
|
||||||
setTypingUsers([]);
|
setTypingUsers([]);
|
||||||
|
setIsMobileSidebarOpen(false);
|
||||||
|
|
||||||
// Utiliser id ou conversation_id selon ce qui est disponible
|
// Utiliser id ou conversation_id selon ce qui est disponible
|
||||||
const conversationId = conversation.id || conversation.conversation_id;
|
const conversationId = conversation.id || conversation.conversation_id;
|
||||||
@ -828,7 +830,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full bg-white">
|
<div className="flex h-full bg-white">
|
||||||
{/* Sidebar des conversations */}
|
{/* Sidebar des conversations */}
|
||||||
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
|
<div className={`${isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex w-full md:w-80 bg-gray-50 border-r border-gray-200 flex-col`}>
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@ -986,12 +988,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zone de chat principale */}
|
{/* Zone de chat principale */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className={`${!isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex flex-1 flex-col`}>
|
||||||
{selectedConversation ? (
|
{selectedConversation ? (
|
||||||
<>
|
<>
|
||||||
{/* En-tête de la conversation */}
|
{/* En-tête de la conversation */}
|
||||||
<div className="p-4 border-b border-gray-200 bg-white">
|
<div className="p-4 border-b border-gray-200 bg-white">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
{/* Bouton retour liste sur mobile */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileSidebarOpen(true)}
|
||||||
|
className="mr-3 p-1 rounded hover:bg-gray-100 md:hidden"
|
||||||
|
aria-label="Retour aux conversations"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
|
|||||||
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';
|
||||||
@ -57,14 +57,14 @@ export default function FlashNotification({
|
|||||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
animate={{ opacity: 1, x: 0 }} // Animation visible
|
||||||
exit={{ opacity: 0, x: 50 }} // Animation de sortie
|
exit={{ opacity: 0, x: 50 }} // Animation de sortie
|
||||||
transition={{ duration: 0.3 }} // Durée des animations
|
transition={{ duration: 0.3 }} // Durée des animations
|
||||||
className="fixed top-5 right-5 flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
|
className="fixed top-5 right-2 left-2 sm:left-auto sm:right-5 sm:max-w-sm flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
|
||||||
>
|
>
|
||||||
{/* Rectangle gauche avec l'icône */}
|
{/* Rectangle gauche avec l'icône */}
|
||||||
<div className={`flex items-center justify-center w-14 ${bg}`}>
|
<div className={`flex items-center justify-center w-14 ${bg}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
{/* Zone de texte */}
|
{/* Zone de texte */}
|
||||||
<div className="flex-1 w-96 p-4">
|
<div className="flex-1 min-w-0 p-4">
|
||||||
<p className="font-bold text-black">{title}</p>
|
<p className="font-bold text-black">{title}</p>
|
||||||
<p className="text-gray-700">{message}</p>
|
<p className="text-gray-700">{message}</p>
|
||||||
{type === 'error' && errorCode && (
|
{type === 'error' && errorCode && (
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Logo from '@/components/Logo';
|
|||||||
|
|
||||||
export default function Footer({ softwareName, softwareVersion }) {
|
export default function Footer({ softwareName, softwareVersion }) {
|
||||||
return (
|
return (
|
||||||
<footer className="absolute bottom-0 left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
|
<footer className="absolute bottom-0 left-0 md:left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
|
||||||
<div className="text-sm font-light">
|
<div className="text-sm font-light">
|
||||||
<span>
|
<span>
|
||||||
© {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.
|
© {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
import { BookOpen, CheckCircle, AlertCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import RadioList from '@/components/Form/RadioList';
|
import RadioList from '@/components/Form/RadioList';
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
@ -86,9 +86,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
|
|||||||
{domaine.domaine_nom}
|
{domaine.domaine_nom}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-emerald-700 text-xl">
|
{openDomains[domaine.domaine_id]
|
||||||
{openDomains[domaine.domaine_id] ? '▼' : '►'}
|
? <ChevronDown className="w-5 h-5 text-emerald-700" />
|
||||||
</span>
|
: <ChevronRight className="w-5 h-5 text-emerald-700" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{openDomains[domaine.domaine_id] && (
|
{openDomains[domaine.domaine_id] && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@ -99,7 +100,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
|
|||||||
className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline"
|
className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline"
|
||||||
onClick={() => toggleCategory(categorie.categorie_id)}
|
onClick={() => toggleCategory(categorie.categorie_id)}
|
||||||
>
|
>
|
||||||
{openCategories[categorie.categorie_id] ? '▼' : '►'}{' '}
|
{openCategories[categorie.categorie_id]
|
||||||
|
? <ChevronDown className="w-4 h-4" />
|
||||||
|
: <ChevronRight className="w-4 h-4" />
|
||||||
|
}
|
||||||
{categorie.categorie_nom}
|
{categorie.categorie_nom}
|
||||||
</button>
|
</button>
|
||||||
{openCategories[categorie.categorie_id] && (
|
{openCategories[categorie.categorie_id] && (
|
||||||
|
|||||||
18
Front-End/src/components/MobileTopbar.js
Normal file
18
Front-End/src/components/MobileTopbar.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
|
import ProfileSelector from '@/components/ProfileSelector';
|
||||||
|
|
||||||
|
export default function MobileTopbar({ onMenuClick }) {
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-40 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:hidden">
|
||||||
|
<button
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||||
|
aria-label="Ouvrir le menu"
|
||||||
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
</button>
|
||||||
|
<ProfileSelector compact />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,11 +6,11 @@ const Pagination = ({ currentPage, totalPages, onPageChange }) => {
|
|||||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
{t('page')} {currentPage} {t('of')} {pages.length}
|
{t('page')} {currentPage} {t('of')} {pages.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
{currentPage > 1 && (
|
{currentPage > 1 && (
|
||||||
<PaginationButton
|
<PaginationButton
|
||||||
text={t('previous')}
|
text={t('previous')}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
BASE_URL,
|
BASE_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
|
||||||
const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
||||||
const {
|
const {
|
||||||
establishments,
|
establishments,
|
||||||
selectedRoleId,
|
selectedRoleId,
|
||||||
@ -103,11 +103,29 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
// Suppression du tronquage JS, on utilise uniquement CSS
|
// Suppression du tronquage JS, on utilise uniquement CSS
|
||||||
const isSingleRole = establishments && establishments.length === 1;
|
const isSingleRole = establishments && establishments.length === 1;
|
||||||
|
|
||||||
return (
|
const buttonContent = compact ? (
|
||||||
<div className={`relative ${className}`}>
|
/* Mode compact : avatar seul pour la topbar mobile */
|
||||||
<DropdownMenu
|
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
|
||||||
buttonContent={
|
<div className="relative">
|
||||||
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
<Image
|
||||||
|
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
||||||
|
alt="Profile"
|
||||||
|
className="w-8 h-8 rounded-full object-cover shadow-md"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||||
|
title={getStatusTitle()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-gray-500 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Mode normal : avatar + infos texte */
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
||||||
@ -116,7 +134,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
/>
|
/>
|
||||||
{/* Bulle de statut de connexion au chat */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||||
title={getStatusTitle()}
|
title={getStatusTitle()}
|
||||||
@ -146,7 +163,12 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<DropdownMenu
|
||||||
|
buttonContent={buttonContent}
|
||||||
items={
|
items={
|
||||||
isSingleRole
|
isSingleRole
|
||||||
? [
|
? [
|
||||||
@ -190,7 +212,10 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
buttonClassName="w-full"
|
buttonClassName="w-full"
|
||||||
menuClassName="absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10"
|
menuClassName={compact
|
||||||
|
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
|
||||||
|
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
|
||||||
|
}
|
||||||
dropdownOpen={dropdownOpen}
|
dropdownOpen={dropdownOpen}
|
||||||
setDropdownOpen={setDropdownOpen}
|
setDropdownOpen={setDropdownOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
15
Front-End/src/components/ServiceWorkerRegister.js
Normal file
15
Front-End/src/components/ServiceWorkerRegister.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
|
export default function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.catch((err) => logger.error('Service worker registration failed:', err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-stone-50 border-r h-full border-gray-200">
|
<div className="w-64 bg-stone-50 border-r h-full border-gray-200">
|
||||||
<div className="border-b border-gray-200 ">
|
<div className="border-b border-gray-200 hidden md:block">
|
||||||
<ProfileSelector className="border-none h-24" />
|
<ProfileSelector className="border-none h-24" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1 px-4 py-6">
|
<nav className="space-y-1 px-4 py-6">
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
const SidebarTabs = ({ tabs, onTabChange }) => {
|
const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||||
|
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||||
|
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
|
||||||
const handleTabChange = (tabId) => {
|
const handleTabChange = (tabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
@ -11,25 +15,79 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateArrows = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setShowLeftArrow(el.scrollLeft > 0);
|
||||||
|
setShowRightArrow(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateArrows();
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('scroll', updateArrows);
|
||||||
|
window.addEventListener('resize', updateArrows);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', updateArrows);
|
||||||
|
window.removeEventListener('resize', updateArrows);
|
||||||
|
};
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
|
const scroll = (direction) => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction === 'left' ? -150 : 150, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<div className="flex flex-col h-full w-full">
|
||||||
{/* Tabs Header */}
|
{/* Tabs Header */}
|
||||||
<div className="flex h-14 bg-gray-50 border-b border-gray-200 shadow-sm">
|
<div className="relative flex items-center bg-gray-50 border-b border-gray-200 shadow-sm">
|
||||||
|
{/* Flèche gauche */}
|
||||||
|
{showLeftArrow && (
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('left')}
|
||||||
|
className="absolute left-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-r from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
|
||||||
|
aria-label="Tabs précédents"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={22} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des onglets scrollable */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex overflow-x-auto scrollbar-none scroll-smooth"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`flex-1 text-center p-4 font-medium transition-colors duration-200 ${
|
onClick={() => handleTabChange(tab.id)}
|
||||||
|
className={`flex-shrink-0 whitespace-nowrap h-14 px-5 font-medium transition-colors duration-200 ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
|
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
|
||||||
: 'text-gray-500 hover:text-emerald-500'
|
: 'text-gray-500 hover:text-emerald-500'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleTabChange(tab.id)}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Flèche droite */}
|
||||||
|
{showRightArrow && (
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('right')}
|
||||||
|
className="absolute right-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-l from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
|
||||||
|
aria-label="Tabs suivants"
|
||||||
|
>
|
||||||
|
<ChevronRight size={22} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tabs Content */}
|
{/* Tabs Content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
|
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
@ -38,10 +96,10 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
|||||||
activeTab === tab.id && (
|
activeTab === tab.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
initial={{ opacity: 0, x: 50 }} // Animation d'entrée
|
initial={{ opacity: 0, x: 50 }}
|
||||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -50 }} // Animation de sortie
|
exit={{ opacity: 0, x: -50 }}
|
||||||
transition={{ duration: 0.3 }} // Durée des animations
|
transition={{ duration: 0.3 }}
|
||||||
className="flex-1 flex flex-col h-full min-h-0"
|
className="flex-1 flex flex-col h-full min-h-0"
|
||||||
>
|
>
|
||||||
{tab.content}
|
{tab.content}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { CheckCircle, Circle } from 'lucide-react';
|
import { CheckCircle, Circle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
|
||||||
const TreeView = forwardRef(function TreeView(
|
const TreeView = forwardRef(function TreeView(
|
||||||
@ -80,20 +80,27 @@ const TreeView = forwardRef(function TreeView(
|
|||||||
{data.map((domaine) => (
|
{data.map((domaine) => (
|
||||||
<div key={domaine.domaine_id} className="mb-4">
|
<div key={domaine.domaine_id} className="mb-4">
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800"
|
className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800 flex items-center gap-2"
|
||||||
onClick={() => toggleDomain(domaine.domaine_id)}
|
onClick={() => toggleDomain(domaine.domaine_id)}
|
||||||
>
|
>
|
||||||
{openDomains[domaine.domaine_id] ? '▼' : '►'} {domaine.domaine_nom}
|
{openDomains[domaine.domaine_id]
|
||||||
|
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
|
||||||
|
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||||
|
}
|
||||||
|
{domaine.domaine_nom}
|
||||||
</button>
|
</button>
|
||||||
{openDomains[domaine.domaine_id] && (
|
{openDomains[domaine.domaine_id] && (
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
{domaine.categories.map((categorie) => (
|
{domaine.categories.map((categorie) => (
|
||||||
<div key={categorie.categorie_id} className="mb-2">
|
<div key={categorie.categorie_id} className="mb-2">
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700"
|
className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700 flex items-center gap-2"
|
||||||
onClick={() => toggleCategory(categorie.categorie_id)}
|
onClick={() => toggleCategory(categorie.categorie_id)}
|
||||||
>
|
>
|
||||||
{openCategories[categorie.categorie_id] ? '▼' : '►'}
|
{openCategories[categorie.categorie_id]
|
||||||
|
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
|
||||||
|
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||||
|
}
|
||||||
{categorie.categorie_nom}
|
{categorie.categorie_nom}
|
||||||
</button>
|
</button>
|
||||||
{openCategories[categorie.categorie_id] && (
|
{openCategories[categorie.categorie_id] && (
|
||||||
|
|||||||
@ -130,6 +130,12 @@ const ClassesSection = ({
|
|||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [classes]);
|
||||||
|
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
|
||||||
|
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
||||||
const { getNiveauxLabels, allNiveaux } = useClasses();
|
const { getNiveauxLabels, allNiveaux } = useClasses();
|
||||||
@ -555,7 +561,7 @@ const ClassesSection = ({
|
|||||||
onClick={handleAddClass}
|
onClick={handleAddClass}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newClass ? [newClass, ...classes] : classes}
|
data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
renderCell={renderClassCell}
|
renderCell={renderClassCell}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
@ -565,6 +571,10 @@ const ClassesSection = ({
|
|||||||
message="Veuillez procéder à la création d'une nouvelle classe."
|
message="Veuillez procéder à la création d'une nouvelle classe."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={popupVisible}
|
isOpen={popupVisible}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
|
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
||||||
@ -28,6 +28,12 @@ const SpecialitiesSection = ({
|
|||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [specialities]);
|
||||||
|
useEffect(() => { if (newSpeciality) setCurrentPage(1); }, [newSpeciality]);
|
||||||
|
const totalPages = Math.ceil(specialities.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
|
|
||||||
@ -253,7 +259,7 @@ const SpecialitiesSection = ({
|
|||||||
onClick={handleAddSpeciality}
|
onClick={handleAddSpeciality}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newSpeciality ? [newSpeciality, ...specialities] : specialities}
|
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
renderCell={renderSpecialityCell}
|
renderCell={renderSpecialityCell}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
@ -263,6 +269,10 @@ const SpecialitiesSection = ({
|
|||||||
message="Veuillez procéder à la création d'une nouvelle spécialité."
|
message="Veuillez procéder à la création d'une nouvelle spécialité."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={popupVisible}
|
isOpen={popupVisible}
|
||||||
|
|||||||
@ -24,7 +24,9 @@ const StructureManagement = ({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ClassesProvider>
|
<ClassesProvider>
|
||||||
<div className="mt-8 w-2/5">
|
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
|
||||||
|
<div className="mt-8 flex flex-col xl:flex-row gap-8">
|
||||||
|
<div className="w-full xl:w-2/5">
|
||||||
<SpecialitiesSection
|
<SpecialitiesSection
|
||||||
specialities={specialities}
|
specialities={specialities}
|
||||||
setSpecialities={setSpecialities}
|
setSpecialities={setSpecialities}
|
||||||
@ -48,7 +50,7 @@ const StructureManagement = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-4/5 mt-12">
|
<div className="w-full xl:flex-1">
|
||||||
<TeachersSection
|
<TeachersSection
|
||||||
teachers={teachers}
|
teachers={teachers}
|
||||||
setTeachers={setTeachers}
|
setTeachers={setTeachers}
|
||||||
@ -70,7 +72,8 @@ const StructureManagement = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-12">
|
</div>
|
||||||
|
<div className="w-full mt-8 xl:mt-12">
|
||||||
<ClassesSection
|
<ClassesSection
|
||||||
classes={classes}
|
classes={classes}
|
||||||
setClasses={setClasses}
|
setClasses={setClasses}
|
||||||
|
|||||||
@ -137,6 +137,12 @@ const TeachersSection = ({
|
|||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [teachers]);
|
||||||
|
useEffect(() => { if (newTeacher) setCurrentPage(1); }, [newTeacher]);
|
||||||
|
const totalPages = Math.ceil(teachers.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
|
|
||||||
@ -535,7 +541,7 @@ const TeachersSection = ({
|
|||||||
onClick={handleAddTeacher}
|
onClick={handleAddTeacher}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newTeacher ? [newTeacher, ...teachers] : teachers}
|
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
renderCell={renderTeacherCell}
|
renderCell={renderTeacherCell}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
@ -545,6 +551,10 @@ const TeachersSection = ({
|
|||||||
message="Veuillez procéder à la création d'un nouvel enseignant."
|
message="Veuillez procéder à la création d'un nouvel enseignant."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={popupVisible}
|
isOpen={popupVisible}
|
||||||
|
|||||||
@ -812,9 +812,9 @@ export default function FilesGroupsManagement({
|
|||||||
<div className="mb-8">{renderExplanation()}</div>
|
<div className="mb-8">{renderExplanation()}</div>
|
||||||
|
|
||||||
{/* 2 colonnes : groupes à gauche, documents à droite */}
|
{/* 2 colonnes : groupes à gauche, documents à droite */}
|
||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-col xl:flex-row gap-8">
|
||||||
{/* Colonne groupes (1/3) */}
|
{/* Colonne groupes (plein écran mobile/tablette, 1/3 desktop) */}
|
||||||
<div className="flex flex-col w-1/3 min-w-[320px] max-w-md">
|
<div className="flex flex-col w-full xl:w-1/3 xl:min-w-[320px] xl:max-w-md">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<SectionTitle title="Liste des dossiers d'inscriptions" />
|
<SectionTitle title="Liste des dossiers d'inscriptions" />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@ -862,8 +862,8 @@ export default function FilesGroupsManagement({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Colonne documents (2/3) */}
|
{/* Colonne documents (plein écran mobile/tablette, 2/3 desktop) */}
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-full xl:flex-1">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<SectionTitle title="Liste des documents" />
|
<SectionTitle title="Liste des documents" />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
|
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
|
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
|
||||||
@ -26,6 +26,11 @@ export default function ParentFilesSection({
|
|||||||
const [selectedGroups, setSelectedGroups] = useState([]); // Gestion des groupes sélectionnés
|
const [selectedGroups, setSelectedGroups] = useState([]); // Gestion des groupes sélectionnés
|
||||||
|
|
||||||
const [guardianDetails, setGuardianDetails] = useState([]);
|
const [guardianDetails, setGuardianDetails] = useState([]);
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [parentFiles]);
|
||||||
|
const totalPages = Math.ceil(parentFiles.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedParentFiles = parentFiles.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
@ -347,10 +352,14 @@ export default function ParentFilesSection({
|
|||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={
|
data={
|
||||||
editingDocumentId === 'new' ? [formData, ...parentFiles] : parentFiles
|
editingDocumentId === 'new' ? [formData, ...pagedParentFiles] : pagedParentFiles
|
||||||
}
|
}
|
||||||
columns={columnsRequiredDocuments}
|
columns={columnsRequiredDocuments}
|
||||||
emptyMessage="Aucune pièce à fournir enregistrée"
|
emptyMessage="Aucune pièce à fournir enregistrée"
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={removePopupVisible}
|
isOpen={removePopupVisible}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react';
|
import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
@ -32,6 +32,12 @@ const DiscountsSection = ({
|
|||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [discounts]);
|
||||||
|
useEffect(() => { if (newDiscount) setCurrentPage(1); }, [newDiscount]);
|
||||||
|
const totalPages = Math.ceil(discounts.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedDiscounts = discounts.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const { selectedEstablishmentId } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
|
||||||
@ -398,11 +404,15 @@ const DiscountsSection = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Table
|
<Table
|
||||||
data={newDiscount ? [newDiscount, ...discounts] : discounts}
|
data={newDiscount ? [newDiscount, ...pagedDiscounts] : pagedDiscounts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
renderCell={renderDiscountCell}
|
renderCell={renderDiscountCell}
|
||||||
defaultTheme="bg-yellow-50"
|
defaultTheme="bg-yellow-50"
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={popupVisible}
|
isOpen={popupVisible}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react';
|
import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
@ -37,6 +37,12 @@ const FeesSection = ({
|
|||||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [fees]);
|
||||||
|
useEffect(() => { if (newFee) setCurrentPage(1); }, [newFee]);
|
||||||
|
const totalPages = Math.ceil(fees.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedFees = fees.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
// En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
|
// En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
|
||||||
const labelTypeFrais = (feeType) =>
|
const labelTypeFrais = (feeType) =>
|
||||||
feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
|
feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
|
||||||
@ -372,10 +378,14 @@ const FeesSection = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Table
|
<Table
|
||||||
data={newFee ? [newFee, ...fees] : fees}
|
data={newFee ? [newFee, ...pagedFees] : pagedFees}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
renderCell={renderFeeCell}
|
renderCell={renderFeeCell}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
<Popup
|
<Popup
|
||||||
isOpen={popupVisible}
|
isOpen={popupVisible}
|
||||||
|
|||||||
@ -7,9 +7,9 @@ const Table = ({
|
|||||||
columns,
|
columns,
|
||||||
renderCell,
|
renderCell,
|
||||||
itemsPerPage = 0,
|
itemsPerPage = 0,
|
||||||
currentPage,
|
currentPage = 1,
|
||||||
totalPages,
|
totalPages = 1,
|
||||||
onPageChange,
|
onPageChange = () => {},
|
||||||
onRowClick,
|
onRowClick,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
isSelectable = false,
|
isSelectable = false,
|
||||||
@ -21,9 +21,9 @@ const Table = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-stone-50 rounded-lg border border-gray-300 shadow-md">
|
<div className="md:bg-stone-50 md:rounded-lg md:border md:border-gray-300 md:shadow-md">
|
||||||
<table className="min-w-full bg-stone-50">
|
<table className="responsive-table min-w-full bg-stone-50">
|
||||||
<thead>
|
<thead className="uppercase">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column, index) => (
|
{columns.map((column, index) => (
|
||||||
<th
|
<th
|
||||||
@ -64,6 +64,7 @@ const Table = ({
|
|||||||
{columns.map((column, colIndex) => (
|
{columns.map((column, colIndex) => (
|
||||||
<td
|
<td
|
||||||
key={colIndex}
|
key={colIndex}
|
||||||
|
data-label={column.name}
|
||||||
className={`py-2 px-4 border-b border-gray-300 text-center text-sm ${
|
className={`py-2 px-4 border-b border-gray-300 text-center text-sm ${
|
||||||
selectedRows?.includes(row.id)
|
selectedRows?.includes(row.id)
|
||||||
? 'text-white'
|
? 'text-white'
|
||||||
@ -84,7 +85,7 @@ const Table = ({
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{itemsPerPage > 0 && data && data.length > 0 && (
|
{itemsPerPage > 0 && totalPages > 1 && data && data.length > 0 && (
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
@ -105,9 +106,9 @@ Table.propTypes = {
|
|||||||
).isRequired,
|
).isRequired,
|
||||||
renderCell: PropTypes.func,
|
renderCell: PropTypes.func,
|
||||||
itemsPerPage: PropTypes.number,
|
itemsPerPage: PropTypes.number,
|
||||||
currentPage: PropTypes.number.isRequired,
|
currentPage: PropTypes.number,
|
||||||
totalPages: PropTypes.number.isRequired,
|
totalPages: PropTypes.number,
|
||||||
onPageChange: PropTypes.func.isRequired,
|
onPageChange: PropTypes.func,
|
||||||
onRowClick: PropTypes.func,
|
onRowClick: PropTypes.func,
|
||||||
selectedRows: PropTypes.arrayOf(PropTypes.any),
|
selectedRows: PropTypes.arrayOf(PropTypes.any),
|
||||||
isSelectable: PropTypes.bool,
|
isSelectable: PropTypes.bool,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const Tooltip = ({ content, children }) => {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{visible && (
|
{visible && (
|
||||||
<div className="absolute z-10 w-64 p-2 bg-white border border-gray-200 rounded shadow-lg">
|
<div className="absolute z-10 w-max max-w-[min(16rem,calc(100vw-2rem))] p-2 bg-white border border-gray-200 rounded shadow-lg">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -88,3 +88,62 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Masquer la scrollbar sur les conteneurs de navigation par onglets */
|
||||||
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
Responsive table — mode "stacked" sur mobile
|
||||||
|
Sur md+ : table classique
|
||||||
|
Sous md : chaque ligne → carte verticale,
|
||||||
|
chaque cellule affiche son label
|
||||||
|
============================================= */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.responsive-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.responsive-table {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.responsive-table tbody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tbody tr {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: #fafaf9; /* stone-50 */
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.07);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tbody td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label = nom de la colonne injecté via data-label */
|
||||||
|
.responsive-table tbody td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #6b7280; /* gray-500 */
|
||||||
|
text-align: left;
|
||||||
|
margin-right: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,13 +15,28 @@ const options = {
|
|||||||
authorize: async (credentials) => {
|
authorize: async (credentials) => {
|
||||||
// URL calculée ici (pas au niveau module) pour garantir que NEXT_PUBLIC_API_URL est chargé
|
// URL calculée ici (pas au niveau module) pour garantir que NEXT_PUBLIC_API_URL est chargé
|
||||||
const loginUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/login`;
|
const loginUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/login`;
|
||||||
|
const csrfUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/csrf`;
|
||||||
try {
|
try {
|
||||||
|
// Récupération server-side du CSRF token + cookie Django
|
||||||
|
const csrfRes = await fetch(csrfUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Connection: 'close' },
|
||||||
|
});
|
||||||
|
const csrfData = await csrfRes.json();
|
||||||
|
const csrfToken = csrfData?.csrfToken;
|
||||||
|
// Extraction du cookie csrftoken depuis la réponse
|
||||||
|
const rawCookies = csrfRes.headers.get('set-cookie') || '';
|
||||||
|
const csrfCookieMatch = rawCookies.match(/csrftoken=([^;]+)/);
|
||||||
|
const csrfCookie = csrfCookieMatch ? csrfCookieMatch[1] : csrfToken;
|
||||||
|
|
||||||
const res = await fetch(loginUrl, {
|
const res = await fetch(loginUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
// Connection: close évite le SocketError undici lié au keep-alive vers Daphne
|
// Connection: close évite le SocketError undici lié au keep-alive vers Daphne
|
||||||
Connection: 'close',
|
Connection: 'close',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
Cookie: `csrftoken=${csrfCookie}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
|
|||||||
@ -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