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:
# 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
access_payload = {

View File

@ -7,11 +7,17 @@ class StructureType(models.IntegerChoices):
PRIMAIRE = 2, _('Primaire')
SECONDAIRE = 3, _('Secondaire')
class EvaluationFrequency(models.IntegerChoices):
TRIMESTER = 1, _("Trimestre")
SEMESTER = 2, _("Semestre")
YEAR = 3, _("Année")
class Establishment(models.Model):
name = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255)
total_capacity = models.IntegerField()
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)
is_active = models.BooleanField(default=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}"
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):
"""
@ -94,7 +110,6 @@ class Student(models.Model):
birth_place = models.CharField(max_length=200, default="", blank=True)
birth_postal_code = models.IntegerField(default=0, 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
profiles = models.ManyToManyField('Auth.Profile', blank=True)
@ -187,6 +202,15 @@ class Student(models.Model):
return self.birth_date.strftime('%d-%m-%Y')
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):
name = models.CharField(max_length=255, default="")
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')
score = models.IntegerField(null=True, blank=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:
unique_together = ('student', 'establishment_competency')
unique_together = ('student', 'establishment_competency', 'period')
indexes = [
models.Index(fields=['student', 'establishment_competency']),
models.Index(fields=['student', 'establishment_competency', 'period']),
]
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) #######
class RegistrationParentFileTemplate(models.Model):

View File

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

View File

@ -33,6 +33,7 @@
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br>
<strong>Niveau :</strong> {{ student.level }}<br>
<strong>Classe :</strong> {{ student.class_name }}<br>
<strong>Période :</strong> {{ period }}<br>
<strong>Date :</strong> {{ date }}
</div>
@ -70,5 +71,42 @@
</tbody>
</table>
{% 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>
</html>

View File

@ -26,6 +26,7 @@ from Subscriptions.models import (
)
from Subscriptions.automate import updateStateMachine
from School.models import EstablishmentCompetency
from Establishment.models import Establishment
from N3wtSchool import settings, bdd
from django.db.models import Q
@ -392,11 +393,28 @@ class RegisterFormWithIdView(APIView):
competency__category__domain__cycle=cycle
)
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:
StudentCompetency.objects.get_or_create(
student=student,
establishment_competency=ec
)
for period in periods:
StudentCompetency.objects.get_or_create(
student=student,
establishment_competency=ec,
period=period
)
except Exception as 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 Subscriptions.models import StudentCompetency, Student
from Common.models import Domain
from django.conf import settings
from Subscriptions.models import BilanCompetence
from datetime import date
from N3wtSchool.renderers import render_to_pdf
from django.core.files import File
from io import BytesIO
import os
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ logger = logging.getLogger(__name__)
class StudentCompetencyListCreateView(APIView):
def get(self, request):
student_id = request.GET.get('student_id')
period = request.GET.get('period')
if not student_id:
return JsonResponse({'error': 'student_id requis'}, status=400)
try:
@ -28,7 +30,12 @@ class StudentCompetencyListCreateView(APIView):
except Student.DoesNotExist:
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__competency',
'establishment_competency__competency__category',
@ -64,6 +71,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": comp.name,
"score": sc.score,
"comment": sc.comment or "",
"period":sc.period or ""
})
total_competencies += 1
# Cas compétence custom
@ -73,6 +81,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": ec.custom_name,
"score": sc.score,
"comment": sc.comment or "",
"period":sc.period or ""
})
total_competencies += 1
if categorie_dict["competences"]:
@ -99,6 +108,7 @@ class StudentCompetencyListCreateView(APIView):
comp_id = item.get("competenceId")
grade = item.get("grade")
student_id = item.get('studentId')
period = item.get('period')
if comp_id is None or grade is None:
errors.append({"competenceId": comp_id, "error": "champ manquant"})
continue
@ -106,7 +116,8 @@ class StudentCompetencyListCreateView(APIView):
# Ajoute le filtre student_id
sc = StudentCompetency.objects.get(
establishment_competency_id=comp_id,
student_id=student_id
student_id=student_id,
period=period
)
sc.score = grade
sc.save()
@ -117,8 +128,8 @@ class StudentCompetencyListCreateView(APIView):
# Génération du PDF si au moins une compétence a été mise à jour
if updated:
student = Student.objects.get(id=student_id)
# Reconstituer la structure "domaines" comme dans le GET
student_competencies = StudentCompetency.objects.filter(student=student).select_related(
# Reconstituer la structure "domaines" pour la période concernée uniquement
student_competencies = StudentCompetency.objects.filter(student=student, period=period).select_related(
'establishment_competency',
'establishment_competency__competency',
'establishment_competency__competency__category',
@ -158,7 +169,7 @@ class StudentCompetencyListCreateView(APIView):
domaine_dict["categories"].append(categorie_dict)
if domaine_dict["categories"]:
result.append(domaine_dict)
context = {
"student": {
"first_name": student.first_name,
@ -166,37 +177,38 @@ class StudentCompetencyListCreateView(APIView):
"level": student.level,
"class_name": student.associated_class.atmosphere_name,
},
"period": period,
"date": date.today().strftime("%d/%m/%Y"),
"domaines": result,
}
print('génération du PDF...')
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:
filename = f"bilan_competences_{student.last_name}_{student.first_name}.pdf"
student.bilan_form.save(
os.path.basename(filename), # Utiliser uniquement le nom de fichier
filename = f"bilan_competences_{student.last_name}_{student.first_name}_{period}.pdf"
# Vérifier si un bilan existe déjà pour cet élève et cette période
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)),
save=True
)
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde du fichier PDF : {e}")
raise
# Exemple : retour du PDF dans la réponse HTTP (pour test)
return JsonResponse({"updated": updated, "errors": errors}, status=200)
@ -204,18 +216,4 @@ class StudentCompetencyListCreateView(APIView):
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentCompetencySimpleView(APIView):
def get(self, request, id):
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)
return JsonResponse("ok", safe=False, status=status.HTTP_200_OK)

View File

@ -143,7 +143,7 @@ def search_students(request):
'last_name': student.last_name,
'level': getattr(student.level, 'name', ''),
'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
]

View File

@ -12,6 +12,7 @@
"@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^11.11.11",
"ics": "^3.8.1",
"jsonwebtoken": "^9.0.2",
@ -1960,6 +1961,12 @@
"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": {
"version": "4.4.0",
"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",
"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": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

View File

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

View File

@ -23,14 +23,16 @@ import {
} from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import StudentInput from '@/components/Grades/StudentInput';
import { Award, BookOpen } from 'lucide-react';
import { Award, BookOpen, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/InputText';
import dayjs from 'dayjs';
export default function Page() {
const router = useRouter();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [formData, setFormData] = useState({
selectedStudent: null,
@ -39,6 +41,48 @@ export default function Page() {
const [students, setStudents] = useState([]);
const [studentCompetencies, setStudentCompetencies] = useState(null);
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 = [
{
@ -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é
useEffect(() => {
if (formData.selectedStudent) {
fetchStudentCompetencies(formData.selectedStudent)
if (formData.selectedStudent && selectedPeriod) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
fetchStudentCompetencies(formData.selectedStudent, periodString)
.then((data) => {
setStudentCompetencies(data);
// Générer les grades à partir du retour API
@ -146,105 +194,218 @@ export default function Page() {
setGrades({});
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 (
<div className="p-8 space-y-8">
{/* Sélection de l'élève */}
<SectionHeader
icon={BookOpen}
title="Suivi pédagogique"
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 */}
<div className="flex-1 flex flex-row gap-4 items-start">
{/* Section haute : filtre + bouton + photo élève */}
<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">
<StudentInput
<InputText
name="studentSearch"
type="text"
label="Recherche élève"
selectedStudent={
students.find((s) => s.id === formData.selectedStudent) || null
}
setSelectedStudent={(student) =>
handleChange('selectedStudent', student?.id || '')
}
searchStudents={searchStudents}
establishmentId={selectedEstablishmentId}
required
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher un élève"
required={false}
enable={true}
/>
</div>
{/* Sélecteur de période */}
{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">
{(() => {
const student = students.find(
(s) => s.id === formData.selectedStudent
);
if (!student) return null;
return (
<>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-20 h-20 object-cover rounded-full border-4 border-emerald-200 mb-2 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">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="text-center">
<div className="text-base font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-xs text-gray-600 mt-1">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
</div>
<div className="text-xs text-gray-600">
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Bouton bilan de compétences en dessous */}
<Button
primary
onClick={() => {
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
router.push(`${url}`);
}}
className="mt-4 px-6 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600"
icon={<Award className="w-6 h-6" />}
title="Réaliser le bilan de compétences"
<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(
(s) => s.id === formData.selectedStudent
);
if (!student) return null;
return (
<>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
/>
</>
);
})()}
) : (
<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.last_name?.[0]}
</div>
)}
</>
);
})()}
</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}
</div>
<div className="text-xs text-gray-600">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
{' | '}
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Icône PDF si bilan dispo pour la période sélectionnée */}
{selectedPeriod &&
student.bilans &&
Array.isArray(student.bilans) &&
(() => {
// Génère la string de période attendue
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
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>
{/* Colonne 2 : Reste des infos */}
<div className="flex-1">
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
<div className="w-full flex flex-row items-stretch gap-4">
<div className="flex-1 flex items-stretch justify-center h-full">
<Attendance absences={absences} />
</div>
<div className="flex-1 flex items-stretch justify-center h-full">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart
studentCompetencies={studentCompetencies}
/>
</div>
</div>
)}
</div>
</div>
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch mt-8">
<div className="w-3/4 flex flex-row items-stretch gap-4 mx-auto">
<div className="flex-1 flex items-center justify-center">
<Attendance absences={absences} />
</div>
<div className="flex-1 flex items-center justify-center">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
</div>
)}
</div>
);
}

View File

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

View File

@ -42,7 +42,7 @@ import {
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';
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() {
const [formData, setFormData] = useState({

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export default function GradesDomainBarChart({ studentCompetencies }) {
};
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>
<div className="w-full flex flex-col gap-2">
{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;
}
);
const [
selectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentEvaluationFrequencyState,
] = useState(() => {
const storedEstablishmentEvaluationFrequency = +sessionStorage.getItem(
'selectedEstablishmentEvaluationFrequency'
);
return storedEstablishmentEvaluationFrequency;
});
const [selectedRoleId, setSelectedRoleIdState] = useState(() => {
const storedRoleId = +sessionStorage.getItem('selectedRoleId');
return storedRoleId;
@ -36,6 +45,12 @@ export const EstablishmentProvider = ({ children }) => {
sessionStorage.setItem('selectedEstablishmentId', id);
};
const setSelectedEstablishmentEvaluationFrequency = (id) => {
setSelectedEstablishmentEvaluationFrequencyState(id);
logger.debug('setSelectedEstablishmentEvaluationFrequency', id);
sessionStorage.setItem('selectedEstablishmentEvaluationFrequency', id);
};
const setSelectedRoleId = (id) => {
setSelectedRoleIdState(id);
sessionStorage.setItem('selectedRoleId', id);
@ -72,6 +87,7 @@ export const EstablishmentProvider = ({ children }) => {
const userEstablishments = user.roles.map((role, i) => ({
id: role.establishment__id,
name: role.establishment__name,
evaluation_frequency: role.establishment__evaluation_frequency,
role_id: i,
role_type: role.role_type,
}));
@ -85,6 +101,9 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedRoleId(roleIndexDefault);
if (userEstablishments.length > 0) {
setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id);
setSelectedEstablishmentEvaluationFrequency(
userEstablishments[roleIndexDefault].evaluation_frequency
);
setProfileRole(userEstablishments[roleIndexDefault].role_type);
}
if (endInitFunctionHandler) {
@ -112,6 +131,8 @@ export const EstablishmentProvider = ({ children }) => {
clearContext,
selectedEstablishmentId,
setSelectedEstablishmentId,
selectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentEvaluationFrequency,
selectedRoleId,
setSelectedRoleId,
profileRole,