feat: Rattachement d'un dossier de compétences à une période scolaire

(configuration dans l'établissement) [#16]
This commit is contained in:
N3WT DE COMPET
2025-05-22 01:25:34 +02:00
parent 0fe6c76189
commit 7de839ee5c
18 changed files with 450 additions and 274 deletions

View File

@ -223,7 +223,7 @@ def makeToken(user):
""" """
try: try:
# Récupérer tous les rôles de l'utilisateur actifs # Récupérer tous les rôles de l'utilisateur actifs
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name') roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency')
# Générer le JWT avec la bonne syntaxe datetime # Générer le JWT avec la bonne syntaxe datetime
access_payload = { access_payload = {

View File

@ -7,11 +7,17 @@ class StructureType(models.IntegerChoices):
PRIMAIRE = 2, _('Primaire') PRIMAIRE = 2, _('Primaire')
SECONDAIRE = 3, _('Secondaire') SECONDAIRE = 3, _('Secondaire')
class EvaluationFrequency(models.IntegerChoices):
TRIMESTER = 1, _("Trimestre")
SEMESTER = 2, _("Semestre")
YEAR = 3, _("Année")
class Establishment(models.Model): class Establishment(models.Model):
name = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255) address = models.CharField(max_length=255)
total_capacity = models.IntegerField() total_capacity = models.IntegerField()
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices)) establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
evaluation_frequency = models.IntegerField(choices=EvaluationFrequency.choices, default=EvaluationFrequency.TRIMESTER)
licence_code = models.CharField(max_length=100, blank=True) licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -56,7 +56,23 @@ def registration_photo_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}" return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}"
def registration_bilan_form_upload_to(instance, filename): def registration_bilan_form_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.pk}/bilan/{filename}" # On récupère le RegistrationForm lié à l'élève
register_form = getattr(instance.student, 'registrationform', None)
if register_form:
pk = register_form.pk
else:
# fallback sur l'id de l'élève si pas de registrationform
pk = instance.student.pk
return f"registration_files/dossier_rf_{pk}/bilan/{filename}"
class BilanCompetence(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
file = models.FileField(upload_to=registration_bilan_form_upload_to, null=True, blank=True)
period = models.CharField(max_length=20, help_text="Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.student} - {self.period}"
class Student(models.Model): class Student(models.Model):
""" """
@ -94,7 +110,6 @@ class Student(models.Model):
birth_place = models.CharField(max_length=200, default="", blank=True) birth_place = models.CharField(max_length=200, default="", blank=True)
birth_postal_code = models.IntegerField(default=0, blank=True) birth_postal_code = models.IntegerField(default=0, blank=True)
attending_physician = models.CharField(max_length=200, default="", blank=True) attending_physician = models.CharField(max_length=200, default="", blank=True)
bilan_form = models.FileField(null=True,blank=True, upload_to=registration_bilan_form_upload_to)
# Many-to-Many Relationship # Many-to-Many Relationship
profiles = models.ManyToManyField('Auth.Profile', blank=True) profiles = models.ManyToManyField('Auth.Profile', blank=True)
@ -187,6 +202,15 @@ class Student(models.Model):
return self.birth_date.strftime('%d-%m-%Y') return self.birth_date.strftime('%d-%m-%Y')
return None return None
class BilanCompetence(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
file = models.FileField(upload_to=registration_bilan_form_upload_to, null=True, blank=True)
period = models.CharField(max_length=20, help_text="Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.student} - {self.period}"
class RegistrationFileGroup(models.Model): class RegistrationFileGroup(models.Model):
name = models.CharField(max_length=255, default="") name = models.CharField(max_length=255, default="")
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
@ -331,16 +355,22 @@ class StudentCompetency(models.Model):
establishment_competency = models.ForeignKey('School.EstablishmentCompetency', on_delete=models.CASCADE, related_name='student_scores') establishment_competency = models.ForeignKey('School.EstablishmentCompetency', on_delete=models.CASCADE, related_name='student_scores')
score = models.IntegerField(null=True, blank=True) score = models.IntegerField(null=True, blank=True)
comment = models.TextField(blank=True, null=True) comment = models.TextField(blank=True, null=True)
period = models.CharField(
max_length=20,
help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025",
default="",
blank=True
)
class Meta: class Meta:
unique_together = ('student', 'establishment_competency') unique_together = ('student', 'establishment_competency', 'period')
indexes = [ indexes = [
models.Index(fields=['student', 'establishment_competency']), models.Index(fields=['student', 'establishment_competency', 'period']),
] ]
def __str__(self): def __str__(self):
return f"{self.student} - {self.establishment_competency} - Score: {self.score}" return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
####### Parent files templates (par dossier d'inscription) ####### ####### Parent files templates (par dossier d'inscription) #######
class RegistrationParentFileTemplate(models.Model): class RegistrationParentFileTemplate(models.Model):

View File

@ -10,18 +10,16 @@ from .models import (
RegistrationSchoolFileTemplate, RegistrationSchoolFileTemplate,
RegistrationParentFileMaster, RegistrationParentFileMaster,
RegistrationParentFileTemplate, RegistrationParentFileTemplate,
AbsenceManagement AbsenceManagement,
BilanCompetence
) )
from School.models import SchoolClass, Fee, Discount, FeeType from School.models import SchoolClass, Fee, Discount, FeeType
from School.serializers import FeeSerializer, DiscountSerializer
from Auth.models import ProfileRole, Profile from Auth.models import ProfileRole, Profile
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
from GestionMessagerie.models import Messagerie
from GestionNotification.models import Notification from GestionNotification.models import Notification
from N3wtSchool import settings from N3wtSchool import settings
from django.utils import timezone from django.utils import timezone
import pytz import pytz
from datetime import datetime
import Subscriptions.util as util import Subscriptions.util as util
class AbsenceManagementSerializer(serializers.ModelSerializer): class AbsenceManagementSerializer(serializers.ModelSerializer):
@ -415,14 +413,20 @@ class GuardianByDICreationSerializer(serializers.ModelSerializer):
return obj.profile_role.profile.id # Retourne l'ID du profil associé return obj.profile_role.profile.id # Retourne l'ID du profil associé
return None return None
class BilanCompetenceSerializer(serializers.ModelSerializer):
class Meta:
model = BilanCompetence
fields = ['id', 'file', 'period', 'created_at']
class StudentByRFCreationSerializer(serializers.ModelSerializer): 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()
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'] fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs) super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)

View File

@ -33,6 +33,7 @@
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br> <strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br>
<strong>Niveau :</strong> {{ student.level }}<br> <strong>Niveau :</strong> {{ student.level }}<br>
<strong>Classe :</strong> {{ student.class_name }}<br> <strong>Classe :</strong> {{ student.class_name }}<br>
<strong>Période :</strong> {{ period }}<br>
<strong>Date :</strong> {{ date }} <strong>Date :</strong> {{ date }}
</div> </div>
@ -70,5 +71,42 @@
</tbody> </tbody>
</table> </table>
{% endfor %} {% endfor %}
<div style="margin-top: 60px; padding: 0; max-width: 700px;">
<div style="
min-height: 180px;
background: #fff;
border: 1.5px dashed #a7f3d0;
border-radius: 12px;
padding: 24px 24px 18px 24px;
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
margin-bottom: 64px; /* Augmente l'espace après l'encadré */
">
<div style="font-weight: bold; color: #059669; font-size: 1.25em; display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span>Appréciation générale / Commentaire : </span>
</div>
<!-- Espace vide pour écrire -->
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 48px; margin-top: 32px;">
<div>
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Date :</span>
<span style="display: inline-block; min-width: 120px; border-bottom: 1.5px solid #a7f3d0; margin-left: 8px;">&nbsp;</span>
</div>
<div>
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Signature :</span>
<span style="display: inline-block; min-width: 180px; border-bottom: 2px solid #059669; margin-left: 8px;">&nbsp;</span>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@ -26,6 +26,7 @@ from Subscriptions.models import (
) )
from Subscriptions.automate import updateStateMachine from Subscriptions.automate import updateStateMachine
from School.models import EstablishmentCompetency from School.models import EstablishmentCompetency
from Establishment.models import Establishment
from N3wtSchool import settings, bdd from N3wtSchool import settings, bdd
from django.db.models import Q from django.db.models import Q
@ -392,10 +393,27 @@ class RegisterFormWithIdView(APIView):
competency__category__domain__cycle=cycle competency__category__domain__cycle=cycle
) )
establishment_competencies = establishment_competencies.distinct() establishment_competencies = establishment_competencies.distinct()
establishment = registerForm.establishment
evaluation_frequency = establishment.evaluation_frequency # 1=Trimestre, 2=Semestre, 3=Année
school_year = registerForm.school_year # ex: "2024_2025"
establishment_competencies = establishment_competencies.distinct()
periods = []
if evaluation_frequency == 1: # Trimestre
periods = [f"T{i+1}_{school_year}" for i in range(3)]
elif evaluation_frequency == 2: # Semestre
periods = [f"S{i+1}_{school_year}" for i in range(2)]
elif evaluation_frequency == 3: # Année
periods = [f"A_{school_year}"]
for ec in establishment_competencies: for ec in establishment_competencies:
for period in periods:
StudentCompetency.objects.get_or_create( StudentCompetency.objects.get_or_create(
student=student, student=student,
establishment_competency=ec establishment_competency=ec,
period=period
) )
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}") logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")

View File

@ -6,13 +6,14 @@ from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from Subscriptions.models import StudentCompetency, Student from Subscriptions.models import StudentCompetency, Student
from Common.models import Domain from Common.models import Domain
from django.conf import settings from Subscriptions.models import BilanCompetence
from datetime import date from datetime import date
from N3wtSchool.renderers import render_to_pdf from N3wtSchool.renderers import render_to_pdf
from django.core.files import File from django.core.files import File
from io import BytesIO from io import BytesIO
import os import os
import logging import logging
from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ logger = logging.getLogger(__name__)
class StudentCompetencyListCreateView(APIView): class StudentCompetencyListCreateView(APIView):
def get(self, request): def get(self, request):
student_id = request.GET.get('student_id') student_id = request.GET.get('student_id')
period = request.GET.get('period')
if not student_id: if not student_id:
return JsonResponse({'error': 'student_id requis'}, status=400) return JsonResponse({'error': 'student_id requis'}, status=400)
try: try:
@ -28,7 +30,12 @@ class StudentCompetencyListCreateView(APIView):
except Student.DoesNotExist: except Student.DoesNotExist:
return JsonResponse({'error': 'Élève introuvable'}, status=404) return JsonResponse({'error': 'Élève introuvable'}, status=404)
student_competencies = StudentCompetency.objects.filter(student=student).select_related( # Filtrer par student ET period si period est fourni
filter_kwargs = {'student': student}
if period:
filter_kwargs['period'] = period
student_competencies = StudentCompetency.objects.filter(**filter_kwargs).select_related(
'establishment_competency', 'establishment_competency',
'establishment_competency__competency', 'establishment_competency__competency',
'establishment_competency__competency__category', 'establishment_competency__competency__category',
@ -64,6 +71,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": comp.name, "nom": comp.name,
"score": sc.score, "score": sc.score,
"comment": sc.comment or "", "comment": sc.comment or "",
"period":sc.period or ""
}) })
total_competencies += 1 total_competencies += 1
# Cas compétence custom # Cas compétence custom
@ -73,6 +81,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": ec.custom_name, "nom": ec.custom_name,
"score": sc.score, "score": sc.score,
"comment": sc.comment or "", "comment": sc.comment or "",
"period":sc.period or ""
}) })
total_competencies += 1 total_competencies += 1
if categorie_dict["competences"]: if categorie_dict["competences"]:
@ -99,6 +108,7 @@ class StudentCompetencyListCreateView(APIView):
comp_id = item.get("competenceId") comp_id = item.get("competenceId")
grade = item.get("grade") grade = item.get("grade")
student_id = item.get('studentId') student_id = item.get('studentId')
period = item.get('period')
if comp_id is None or grade is None: if comp_id is None or grade is None:
errors.append({"competenceId": comp_id, "error": "champ manquant"}) errors.append({"competenceId": comp_id, "error": "champ manquant"})
continue continue
@ -106,7 +116,8 @@ class StudentCompetencyListCreateView(APIView):
# Ajoute le filtre student_id # Ajoute le filtre student_id
sc = StudentCompetency.objects.get( sc = StudentCompetency.objects.get(
establishment_competency_id=comp_id, establishment_competency_id=comp_id,
student_id=student_id student_id=student_id,
period=period
) )
sc.score = grade sc.score = grade
sc.save() sc.save()
@ -117,8 +128,8 @@ class StudentCompetencyListCreateView(APIView):
# Génération du PDF si au moins une compétence a été mise à jour # Génération du PDF si au moins une compétence a été mise à jour
if updated: if updated:
student = Student.objects.get(id=student_id) student = Student.objects.get(id=student_id)
# Reconstituer la structure "domaines" comme dans le GET # Reconstituer la structure "domaines" pour la période concernée uniquement
student_competencies = StudentCompetency.objects.filter(student=student).select_related( student_competencies = StudentCompetency.objects.filter(student=student, period=period).select_related(
'establishment_competency', 'establishment_competency',
'establishment_competency__competency', 'establishment_competency__competency',
'establishment_competency__competency__category', 'establishment_competency__competency__category',
@ -166,37 +177,38 @@ class StudentCompetencyListCreateView(APIView):
"level": student.level, "level": student.level,
"class_name": student.associated_class.atmosphere_name, "class_name": student.associated_class.atmosphere_name,
}, },
"period": period,
"date": date.today().strftime("%d/%m/%Y"), "date": date.today().strftime("%d/%m/%Y"),
"domaines": result, "domaines": result,
} }
print('génération du PDF...')
pdf_result = render_to_pdf('pdfs/bilan_competences.html', context) pdf_result = render_to_pdf('pdfs/bilan_competences.html', context)
# Vérifier si un fichier bilan_form existe déjà et le supprimer
if student.bilan_form and student.bilan_form.name:
if os.path.isabs(student.bilan_form.path):
existing_file_path = student.bilan_form.path
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, student.bilan_form.name.lstrip('/'))
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
student.bilan_form.delete(save=False)
logger.info(f"Ancien PDF supprimé : {existing_file_path}")
else:
logger.info(f"File does not exist: {existing_file_path}")
try: try:
filename = f"bilan_competences_{student.last_name}_{student.first_name}.pdf" filename = f"bilan_competences_{student.last_name}_{student.first_name}_{period}.pdf"
student.bilan_form.save( # Vérifier si un bilan existe déjà pour cet élève et cette période
os.path.basename(filename), # Utiliser uniquement le nom de fichier existing_bilan = BilanCompetence.objects.filter(student=student, period=period).first()
if existing_bilan:
# Supprimer le fichier physique si présent
if existing_bilan.file and existing_bilan.file.name:
file_path = existing_bilan.file.path
if os.path.exists(file_path):
os.remove(file_path)
existing_bilan.delete()
bilan = BilanCompetence.objects.create(
student=student,
period=period
)
bilan.file.save(
os.path.basename(filename),
File(BytesIO(pdf_result.content)), File(BytesIO(pdf_result.content)),
save=True save=True
) )
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la sauvegarde du fichier PDF : {e}") logger.error(f"Erreur lors de la sauvegarde du fichier PDF : {e}")
raise raise
# Exemple : retour du PDF dans la réponse HTTP (pour test)
return JsonResponse({"updated": updated, "errors": errors}, status=200) return JsonResponse({"updated": updated, "errors": errors}, status=200)
@ -205,17 +217,3 @@ class StudentCompetencyListCreateView(APIView):
class StudentCompetencySimpleView(APIView): class StudentCompetencySimpleView(APIView):
def get(self, request, id): def get(self, request, id):
return JsonResponse("ok", safe=False, status=status.HTTP_200_OK) return JsonResponse("ok", safe=False, status=status.HTTP_200_OK)
# def put(self, request, id):
# try:
# absence = AbsenceManagement.objects.get(id=id)
# serializer = AbsenceManagementSerializer(absence, data=request.data)
# if serializer.is_valid():
# serializer.save()
# return JsonResponse(serializer.data, safe=False, status=status.HTTP_200_OK)
# return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
# except AbsenceManagement.DoesNotExist:
# return JsonResponse({"error": "Absence not found"}, safe=False, status=status.HTTP_404_NOT_FOUND)
# def delete(self, request, id):
# return delete_object(AbsenceManagement, id)

View File

@ -143,7 +143,7 @@ def search_students(request):
'last_name': student.last_name, 'last_name': student.last_name,
'level': getattr(student.level, 'name', ''), 'level': getattr(student.level, 'name', ''),
'associated_class_name': student.associated_class.atmosphere_name if student.associated_class else '', 'associated_class_name': student.associated_class.atmosphere_name if student.associated_class else '',
'photo': student.photo.url if student.photo else None, 'photo': student.photo.url if student.photo else None
} }
for student in students for student in students
] ]

View File

@ -12,6 +12,7 @@
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"ics": "^3.8.1", "ics": "^3.8.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@ -1960,6 +1961,12 @@
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -7897,6 +7904,11 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==" "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
}, },
"dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"debug": { "debug": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

View File

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"ics": "^3.8.1", "ics": "^3.8.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",

View File

@ -23,14 +23,16 @@ import {
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import StudentInput from '@/components/Grades/StudentInput'; import { Award, BookOpen, FileText } from 'lucide-react';
import { Award, BookOpen } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart'; import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/InputText';
import dayjs from 'dayjs';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses(); const { getNiveauLabel } = useClasses();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
selectedStudent: null, selectedStudent: null,
@ -39,6 +41,48 @@ export default function Page() {
const [students, setStudents] = useState([]); const [students, setStudents] = useState([]);
const [studentCompetencies, setStudentCompetencies] = useState(null); const [studentCompetencies, setStudentCompetencies] = useState(null);
const [grades, setGrades] = useState({}); const [grades, setGrades] = useState({});
const [searchTerm, setSearchTerm] = useState('');
const [selectedPeriod, setSelectedPeriod] = useState(null);
// Définir les périodes selon la fréquence
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 [];
};
// Sélection automatique de la période courante
useEffect(() => {
if (!formData.selectedStudent) {
setSelectedPeriod(null);
return;
}
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);
}, [formData.selectedStudent, selectedEstablishmentEvaluationFrequency]);
const academicResults = [ const academicResults = [
{ {
@ -122,8 +166,12 @@ export default function Page() {
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné // Charger les compétences et générer les grades à chaque changement d'élève sélectionné
useEffect(() => { useEffect(() => {
if (formData.selectedStudent) { if (formData.selectedStudent && selectedPeriod) {
fetchStudentCompetencies(formData.selectedStudent) const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
fetchStudentCompetencies(formData.selectedStudent, periodString)
.then((data) => { .then((data) => {
setStudentCompetencies(data); setStudentCompetencies(data);
// Générer les grades à partir du retour API // Générer les grades à partir du retour API
@ -146,36 +194,86 @@ export default function Page() {
setGrades({}); setGrades({});
setStudentCompetencies(null); setStudentCompetencies(null);
} }
}, [formData.selectedStudent]); }, [formData.selectedStudent, selectedPeriod]);
// Fonction utilitaire pour convertir la période sélectionnée en string backend
function getPeriodString(selectedPeriod, frequency) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
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 '';
}
return ( return (
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
{/* Sélection de l'élève */}
<SectionHeader <SectionHeader
icon={BookOpen} icon={BookOpen}
title="Suivi pédagogique" title="Suivi pédagogique"
description="Suivez le parcours d'un élève" description="Suivez le parcours d'un élève"
/> />
<div className="flex flex-col md:flex-row md:items-start gap-4">
{/* Recherche élève + bouton + fiche élève */} {/* Section haute : filtre + bouton + photo élève */}
<div className="flex-1 flex flex-row gap-4 items-start"> <div className="flex flex-row gap-8 items-start">
{/* Colonne gauche : InputText + bouton */}
<div className="w-4/5 flex items-end gap-4">
<div className="flex-1"> <div className="flex-1">
<StudentInput <InputText
name="studentSearch"
type="text"
label="Recherche élève" label="Recherche élève"
selectedStudent={ value={searchTerm}
students.find((s) => s.id === formData.selectedStudent) || null onChange={(e) => setSearchTerm(e.target.value)}
} placeholder="Rechercher un élève"
setSelectedStudent={(student) => required={false}
handleChange('selectedStudent', student?.id || '') enable={true}
}
searchStudents={searchStudents}
establishmentId={selectedEstablishmentId}
required
/> />
</div> </div>
{/* Sélecteur de période */}
{formData.selectedStudent && ( {formData.selectedStudent && (
<div className="ml-4 flex flex-col items-center min-w-[220px] max-w-xs p-4 rounded-lg border border-emerald-100 shadow"> <SelectChoice
{(() => { name="period"
label="Période"
placeHolder="Choisir la période"
choices={getPeriods().map((period) => {
const today = dayjs();
const start = dayjs(`${today.year()}-${period.start}`);
const end = dayjs(`${today.year()}-${period.end}`);
const isPast = today.isAfter(end);
return {
value: period.value,
label: period.label,
disabled: isPast,
};
})}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(Number(e.target.value))}
disabled={false}
/>
)}
<Button
primary
onClick={() => {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`;
router.push(url);
}}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600"
icon={<Award className="w-6 h-6" />}
text="Evaluer"
title="Evaluez l'élève"
disabled={!formData.selectedStudent || !selectedPeriod}
/>
</div>
{/* Colonne droite : Photo élève */}
<div className="w-2/5 flex flex-col items-center justify-center">
{formData.selectedStudent &&
(() => {
const student = students.find( const student = students.find(
(s) => s.id === formData.selectedStudent (s) => s.id === formData.selectedStudent
); );
@ -186,65 +284,128 @@ export default function Page() {
<img <img
src={`${BASE_URL}${student.photo}`} src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`} alt={`${student.first_name} ${student.last_name}`}
className="w-20 h-20 object-cover rounded-full border-4 border-emerald-200 mb-2 shadow" className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
/> />
) : ( ) : (
<div className="w-20 h-20 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-2 border-4 border-emerald-100"> <div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-4 border-4 border-emerald-100">
{student.first_name?.[0]} {student.first_name?.[0]}
{student.last_name?.[0]} {student.last_name?.[0]}
</div> </div>
)} )}
<div className="text-center"> </>
<div className="text-base font-semibold text-emerald-800"> );
})()}
</div>
</div>
{/* Section basse : liste élèves + infos */}
<div className="flex flex-row gap-8 items-start mt-8">
{/* Colonne 1 : Liste des élèves */}
<div className="w-full max-w-xs">
<h3 className="text-lg font-semibold text-emerald-700 mb-4">
Liste des élèves
</h3>
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100">
{students
.filter(
(student) =>
!searchTerm ||
`${student.last_name} ${student.first_name}`
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
.map((student) => (
<li
key={student.id}
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${
formData.selectedStudent === student.id
? 'bg-emerald-100 border-l-4 border-emerald-400'
: 'border-l-2 border-gray-200'
}`}
onClick={() => handleChange('selectedStudent', student.id)}
>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200"
/>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-lg border-2 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="flex-1">
<div className="font-semibold text-emerald-800">
{student.last_name} {student.first_name} {student.last_name} {student.first_name}
</div> </div>
<div className="text-xs text-gray-600 mt-1"> <div className="text-xs text-gray-600">
Niveau :{' '} Niveau :{' '}
<span className="font-medium"> <span className="font-medium">
{getNiveauLabel(student.level)} {getNiveauLabel(student.level)}
</span> </span>
</div> {' | '}
<div className="text-xs text-gray-600">
Classe :{' '} Classe :{' '}
<span className="font-medium"> <span className="font-medium">
{student.associated_class_name} {student.associated_class_name}
</span> </span>
</div> </div>
</div> </div>
{/* Bouton bilan de compétences en dessous */} {/* Icône PDF si bilan dispo pour la période sélectionnée */}
<Button {selectedPeriod &&
primary student.bilans &&
onClick={() => { Array.isArray(student.bilans) &&
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`; (() => {
router.push(`${url}`); // Génère la string de période attendue
}} const periodString = getPeriodString(
className="mt-4 px-6 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600" selectedPeriod,
icon={<Award className="w-6 h-6" />} selectedEstablishmentEvaluationFrequency
title="Réaliser le bilan de compétences"
/>
</>
); );
const bilan = student.bilans.find(
(b) => b.period === periodString && b.file
);
if (bilan) {
return (
<a
href={`${BASE_URL}${bilan.file}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-emerald-600 hover:text-emerald-800"
title="Télécharger le bilan de compétences"
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève
>
<FileText className="w-5 h-5" />
</a>
);
}
return null;
})()} })()}
</li>
))}
</ul>
</div> </div>
)} {/* Colonne 2 : Reste des infos */}
</div> <div className="flex-1">
</div>
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
{formData.selectedStudent && ( {formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch mt-8"> <div className="flex flex-col gap-8 w-full justify-center items-stretch">
<div className="w-3/4 flex flex-row items-stretch gap-4 mx-auto"> <div className="w-full flex flex-row items-stretch gap-4">
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-stretch justify-center h-full">
<Attendance absences={absences} /> <Attendance absences={absences} />
</div> </div>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-stretch justify-center h-full">
<GradesStatsCircle grades={grades} /> <GradesStatsCircle grades={grades} />
</div> </div>
</div> </div>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<GradesDomainBarChart studentCompetencies={studentCompetencies} /> <GradesDomainBarChart
studentCompetencies={studentCompetencies}
/>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div>
</div>
); );
} }

View File

@ -21,9 +21,10 @@ export default function StudentCompetenciesPage() {
const [studentCompetencies, setStudentCompetencies] = useState([]); const [studentCompetencies, setStudentCompetencies] = useState([]);
const [grades, setGrades] = useState({}); const [grades, setGrades] = useState({});
const studentId = searchParams.get('studentId'); const studentId = searchParams.get('studentId');
const period = searchParams.get('period');
useEffect(() => { useEffect(() => {
fetchStudentCompetencies(studentId) fetchStudentCompetencies(studentId, period)
.then((data) => { .then((data) => {
setStudentCompetencies(data); setStudentCompetencies(data);
}) })
@ -64,6 +65,7 @@ export default function StudentCompetenciesPage() {
studentId, studentId,
competenceId, competenceId,
grade: score, grade: score,
period: period,
})); }));
editStudentCompetencies(data, csrfToken) editStudentCompetencies(data, csrfToken)
.then(() => { .then(() => {

View File

@ -42,7 +42,7 @@ import {
import { fetchProfiles } from '@/app/actions/authAction'; import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url'; import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
export default function CreateSubscriptionPage() { export default function CreateSubscriptionPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({

View File

@ -24,16 +24,18 @@ export const editStudentCompetencies = (data, csrfToken) => {
return fetch(request).then(requestResponseHandler).catch(errorHandler); return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchStudentCompetencies = (id) => { export const fetchStudentCompetencies = (id, period) => {
const request = new Request( // Si period est vide, ne pas l'ajouter à l'URL
`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`, const url = period
{ ? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}`
: `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`;
const request = new Request(url, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
} });
);
return fetch(request).then(requestResponseHandler).catch(errorHandler); return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };

View File

@ -22,7 +22,7 @@ const typeStyles = {
}; };
export default function FlashNotification({ export default function FlashNotification({
displayPeriod = 3000, displayPeriod = 5000,
title, title,
message, message,
type = 'info', type = 'info',

View File

@ -32,7 +32,7 @@ export default function GradesDomainBarChart({ studentCompetencies }) {
}; };
return ( return (
<div className="w-3/4 flex flex-col items-center gap-4 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200"> <div className="w-full flex flex-col items-center gap-4 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold mb-2">Moyenne par domaine</h2> <h2 className="text-xl font-semibold mb-2">Moyenne par domaine</h2>
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
{domainStats.map((d) => ( {domainStats.map((d) => (

View File

@ -1,117 +0,0 @@
import React, { useState, useEffect } from 'react';
import { BASE_URL } from '@/utils/Url';
export default function StudentInput({
label,
selectedStudent,
setSelectedStudent,
searchStudents,
establishmentId,
required = false,
}) {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
// Désélectionner si l'input ne correspond plus à l'élève sélectionné
useEffect(() => {
if (
selectedStudent &&
inputValue !==
`${selectedStudent.last_name} ${selectedStudent.first_name}`
) {
setSelectedStudent(null);
}
// eslint-disable-next-line
}, [inputValue]);
const handleInputChange = async (e) => {
const value = e.target.value;
setInputValue(value);
if (value.trim() !== '') {
try {
const results = await searchStudents(establishmentId, value);
setSuggestions(results);
} catch {
setSuggestions([]);
}
} else {
setSuggestions([]);
}
};
const handleSuggestionClick = (student) => {
setSelectedStudent(student);
setInputValue(`${student.last_name} ${student.first_name}`);
setSuggestions([]);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
handleSuggestionClick(suggestions[selectedIndex]);
}
} else if (e.key === 'ArrowDown') {
setSelectedIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
}
};
return (
<div>
<label className="block text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Rechercher un élève"
className="mt-1 px-3 py-2 block w-full border rounded-md"
required={required}
/>
{suggestions.length > 0 && (
<ul className="border rounded mt-2 bg-white shadow">
{suggestions.map((student, idx) => (
<li
key={student.id}
className={`flex items-center gap-2 p-2 cursor-pointer transition-colors ${
idx === selectedIndex
? 'bg-emerald-100 text-emerald-800'
: 'hover:bg-emerald-50'
}`}
onClick={() => handleSuggestionClick(student)}
onMouseEnter={() => setSelectedIndex(idx)}
>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-8 h-8 object-cover rounded-full border"
/>
) : (
<div className="w-8 h-8 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-semibold">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<span>
{student.last_name} {student.first_name} ({student.level}) -{' '}
{student.associated_class_name}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -12,6 +12,15 @@ export const EstablishmentProvider = ({ children }) => {
return storedEstablishmentId; return storedEstablishmentId;
} }
); );
const [
selectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentEvaluationFrequencyState,
] = useState(() => {
const storedEstablishmentEvaluationFrequency = +sessionStorage.getItem(
'selectedEstablishmentEvaluationFrequency'
);
return storedEstablishmentEvaluationFrequency;
});
const [selectedRoleId, setSelectedRoleIdState] = useState(() => { const [selectedRoleId, setSelectedRoleIdState] = useState(() => {
const storedRoleId = +sessionStorage.getItem('selectedRoleId'); const storedRoleId = +sessionStorage.getItem('selectedRoleId');
return storedRoleId; return storedRoleId;
@ -36,6 +45,12 @@ export const EstablishmentProvider = ({ children }) => {
sessionStorage.setItem('selectedEstablishmentId', id); sessionStorage.setItem('selectedEstablishmentId', id);
}; };
const setSelectedEstablishmentEvaluationFrequency = (id) => {
setSelectedEstablishmentEvaluationFrequencyState(id);
logger.debug('setSelectedEstablishmentEvaluationFrequency', id);
sessionStorage.setItem('selectedEstablishmentEvaluationFrequency', id);
};
const setSelectedRoleId = (id) => { const setSelectedRoleId = (id) => {
setSelectedRoleIdState(id); setSelectedRoleIdState(id);
sessionStorage.setItem('selectedRoleId', id); sessionStorage.setItem('selectedRoleId', id);
@ -72,6 +87,7 @@ export const EstablishmentProvider = ({ children }) => {
const userEstablishments = user.roles.map((role, i) => ({ const userEstablishments = user.roles.map((role, i) => ({
id: role.establishment__id, id: role.establishment__id,
name: role.establishment__name, name: role.establishment__name,
evaluation_frequency: role.establishment__evaluation_frequency,
role_id: i, role_id: i,
role_type: role.role_type, role_type: role.role_type,
})); }));
@ -85,6 +101,9 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedRoleId(roleIndexDefault); setSelectedRoleId(roleIndexDefault);
if (userEstablishments.length > 0) { if (userEstablishments.length > 0) {
setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id); setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id);
setSelectedEstablishmentEvaluationFrequency(
userEstablishments[roleIndexDefault].evaluation_frequency
);
setProfileRole(userEstablishments[roleIndexDefault].role_type); setProfileRole(userEstablishments[roleIndexDefault].role_type);
} }
if (endInitFunctionHandler) { if (endInitFunctionHandler) {
@ -112,6 +131,8 @@ export const EstablishmentProvider = ({ children }) => {
clearContext, clearContext,
selectedEstablishmentId, selectedEstablishmentId,
setSelectedEstablishmentId, setSelectedEstablishmentId,
selectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentEvaluationFrequency,
selectedRoleId, selectedRoleId,
setSelectedRoleId, setSelectedRoleId,
profileRole, profileRole,