mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 23:43:22 +00:00
feat: Génération du bilan de compétence en PDF [#16]
This commit is contained in:
@ -55,6 +55,9 @@ class Sibling(models.Model):
|
|||||||
def registration_photo_upload_to(instance, filename):
|
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):
|
||||||
|
return f"registration_files/dossier_rf_{instance.pk}/bilan/{filename}"
|
||||||
|
|
||||||
class Student(models.Model):
|
class Student(models.Model):
|
||||||
"""
|
"""
|
||||||
Représente l’élève inscrit ou en cours d’inscription.
|
Représente l’élève inscrit ou en cours d’inscription.
|
||||||
@ -91,6 +94,7 @@ 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)
|
||||||
|
|||||||
@ -422,7 +422,7 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Student
|
model = Student
|
||||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name']
|
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
||||||
|
|||||||
74
Back-End/Subscriptions/templates/pdfs/bilan_competences.html
Normal file
74
Back-End/Subscriptions/templates/pdfs/bilan_competences.html
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Bilan de compétences</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 2em; }
|
||||||
|
h1, h2 { color: #059669; }
|
||||||
|
.student-info { margin-bottom: 2em; }
|
||||||
|
.domain-table { width: 100%; border-collapse: collapse; margin-bottom: 2em; }
|
||||||
|
.domain-header th {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
font-size: 1.1em;
|
||||||
|
padding: 0.7em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.category-row td {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #059669;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.6em;
|
||||||
|
}
|
||||||
|
th, td { border: 1px solid #e5e7eb; padding: 0.5em; }
|
||||||
|
th.competence-header { background: #d1fae5; }
|
||||||
|
td.competence-nom { word-break: break-word; max-width: 320px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Bilan de compétences</h1>
|
||||||
|
<div class="student-info">
|
||||||
|
<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>Date :</strong> {{ date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for domaine in domaines %}
|
||||||
|
<table class="domain-table" {% if not forloop.first %}style="page-break-before: always;"{% endif %}>
|
||||||
|
<thead>
|
||||||
|
<tr class="domain-header">
|
||||||
|
<th colspan="4" style="text-align:center">{{ domaine.nom }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="competence-header" style="min-width: 120px;">Compétence</th>
|
||||||
|
<th class="competence-header" style="width: 28px; text-align:center;">1</th>
|
||||||
|
<th class="competence-header" style="width: 28px; text-align:center;">2</th>
|
||||||
|
<th class="competence-header" style="width: 28px; text-align:center;">3</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for categorie in domaine.categories %}
|
||||||
|
<tr class="category-row">
|
||||||
|
<td colspan="4">{{ categorie.nom }}</td>
|
||||||
|
</tr>
|
||||||
|
{% for competence in categorie.competences %}
|
||||||
|
<tr>
|
||||||
|
<td class="competence-nom">{{ competence.nom }}</td>
|
||||||
|
{% for note in "123" %}
|
||||||
|
<td style="text-align:center; width:28px;">
|
||||||
|
{% if competence.score|stringformat:"s" == note %}
|
||||||
|
<span style="color:#059669; font-weight:bold; font-size:1.1em;">✓</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -96,9 +96,6 @@ def getArgFromRequest(_argument, _request):
|
|||||||
resultat = data[_argument]
|
resultat = data[_argument]
|
||||||
return resultat
|
return resultat
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
from PyPDF2 import PdfMerger
|
|
||||||
|
|
||||||
def merge_files_pdf(file_paths):
|
def merge_files_pdf(file_paths):
|
||||||
"""
|
"""
|
||||||
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
|
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
|
||||||
|
|||||||
@ -2,13 +2,19 @@ from django.http.response import JsonResponse
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from drf_yasg import openapi
|
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
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 School.models import Competency
|
from django.conf import settings
|
||||||
from N3wtSchool.bdd import delete_object
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
@ -107,6 +113,91 @@ class StudentCompetencyListCreateView(APIView):
|
|||||||
updated.append(comp_id)
|
updated.append(comp_id)
|
||||||
except StudentCompetency.DoesNotExist:
|
except StudentCompetency.DoesNotExist:
|
||||||
errors.append({"competenceId": comp_id, "error": "not found"})
|
errors.append({"competenceId": comp_id, "error": "not found"})
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
'establishment_competency',
|
||||||
|
'establishment_competency__competency',
|
||||||
|
'establishment_competency__competency__category',
|
||||||
|
'establishment_competency__competency__category__domain',
|
||||||
|
'establishment_competency__custom_category',
|
||||||
|
'establishment_competency__custom_category__domain',
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
domaines = Domain.objects.all()
|
||||||
|
for domaine in domaines:
|
||||||
|
domaine_dict = {
|
||||||
|
"nom": domaine.name,
|
||||||
|
"categories": []
|
||||||
|
}
|
||||||
|
categories = domaine.categories.all()
|
||||||
|
for categorie in categories:
|
||||||
|
categorie_dict = {
|
||||||
|
"nom": categorie.name,
|
||||||
|
"competences": []
|
||||||
|
}
|
||||||
|
for sc in student_competencies:
|
||||||
|
ec = sc.establishment_competency
|
||||||
|
if ec.competency and ec.competency.category_id == categorie.id:
|
||||||
|
comp = ec.competency
|
||||||
|
categorie_dict["competences"].append({
|
||||||
|
"nom": comp.name,
|
||||||
|
"score": sc.score,
|
||||||
|
"comment": sc.comment or "",
|
||||||
|
})
|
||||||
|
elif ec.competency is None and ec.custom_category_id == categorie.id:
|
||||||
|
categorie_dict["competences"].append({
|
||||||
|
"nom": ec.custom_name,
|
||||||
|
"score": sc.score,
|
||||||
|
"comment": sc.comment or "",
|
||||||
|
})
|
||||||
|
if categorie_dict["competences"]:
|
||||||
|
domaine_dict["categories"].append(categorie_dict)
|
||||||
|
if domaine_dict["categories"]:
|
||||||
|
result.append(domaine_dict)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"student": {
|
||||||
|
"first_name": student.first_name,
|
||||||
|
"last_name": student.last_name,
|
||||||
|
"level": student.level,
|
||||||
|
"class_name": student.associated_class.atmosphere_name,
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
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)
|
return JsonResponse({"updated": updated, "errors": errors}, status=200)
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
|
|||||||
@ -136,7 +136,6 @@ def search_students(request):
|
|||||||
registrationform__establishment_id=establishment_id
|
registrationform__establishment_id=establishment_id
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
# Sérialisation simple (adapte selon ton besoin)
|
|
||||||
results = [
|
results = [
|
||||||
{
|
{
|
||||||
'id': student.id,
|
'id': student.id,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ test_mode = os.getenv('TEST_MODE', 'False') == 'True'
|
|||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"],
|
["python", "manage.py", "collectstatic", "--noinput"],
|
||||||
["python", "manage.py", "flush", "--noinput"],
|
# ["python", "manage.py", "flush", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||||
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||||
@ -27,18 +27,18 @@ commands = [
|
|||||||
["python", "manage.py", "migrate", "--noinput"]
|
["python", "manage.py", "migrate", "--noinput"]
|
||||||
]
|
]
|
||||||
|
|
||||||
test_commands = [
|
# test_commands = [
|
||||||
["python", "manage.py", "init_mock_datas"]
|
# ["python", "manage.py", "init_mock_datas"]
|
||||||
]
|
# ]
|
||||||
|
|
||||||
for command in commands:
|
for command in commands:
|
||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if test_mode:
|
# if test_mode:
|
||||||
for test_command in test_commands:
|
# for test_command in test_commands:
|
||||||
if run_command(test_command) != 0:
|
# if run_command(test_command) != 0:
|
||||||
exit(1)
|
# exit(1)
|
||||||
|
|
||||||
# Lancer les processus en parallèle
|
# Lancer les processus en parallèle
|
||||||
processes = [
|
processes = [
|
||||||
|
|||||||
@ -11,7 +11,10 @@ import Orientation from '@/components/Grades/Orientation';
|
|||||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
|
import {
|
||||||
|
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||||
|
BASE_URL,
|
||||||
|
} from '@/utils/Url';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
@ -21,6 +24,9 @@ import {
|
|||||||
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 StudentInput from '@/components/Grades/StudentInput';
|
||||||
|
import { Award, BookOpen } from 'lucide-react';
|
||||||
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -145,9 +151,14 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
{/* Sélection de l'élève */}
|
{/* Sélection de l'élève */}
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
<SectionHeader
|
||||||
<h2 className="text-xl font-semibold mb-4">Sélectionner un élève</h2>
|
icon={BookOpen}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
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">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<StudentInput
|
<StudentInput
|
||||||
label="Recherche élève"
|
label="Recherche élève"
|
||||||
@ -161,49 +172,78 @@ export default function Page() {
|
|||||||
establishmentId={selectedEstablishmentId}
|
establishmentId={selectedEstablishmentId}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{/* <SelectChoice
|
|
||||||
name="selectedStudent"
|
|
||||||
label="Élève"
|
|
||||||
placeHolder="Sélectionnez un élève"
|
|
||||||
selected={formData.selectedStudent || ''}
|
|
||||||
callback={(e) => handleChange('selectedStudent', e.target.value)}
|
|
||||||
choices={students.map((student) => ({
|
|
||||||
value: student.id,
|
|
||||||
label: `${student.last_name} ${student.first_name} - ${getNiveauLabel(
|
|
||||||
student.level
|
|
||||||
)} (${student.associated_class_name})`,
|
|
||||||
}))}
|
|
||||||
required
|
|
||||||
/> */}
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{formData.selectedStudent && (
|
||||||
text="Réaliser le bilan de compétences"
|
<div className="ml-4 flex flex-col items-center min-w-[220px] max-w-xs p-4 rounded-lg border border-emerald-100 shadow">
|
||||||
primary
|
{(() => {
|
||||||
disabled={!formData.selectedStudent}
|
const student = students.find(
|
||||||
onClick={() => {
|
(s) => s.id === formData.selectedStudent
|
||||||
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
|
);
|
||||||
router.push(`${url}`);
|
if (!student) return null;
|
||||||
}}
|
return (
|
||||||
className={`px-6 py-2 rounded-md shadow ${
|
<>
|
||||||
!formData.selectedStudent
|
{student.photo ? (
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
<img
|
||||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
{/* <AcademicResults results={academicResults} /> */}
|
<div className="w-3/4 flex flex-row items-stretch gap-4 mx-auto">
|
||||||
<Attendance absences={absences} />
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<GradesStatsCircle grades={grades} />
|
<Attendance absences={absences} />
|
||||||
{/* <Remarks remarks={remarks} />
|
</div>
|
||||||
<WorkPlan workPlan={workPlan} />
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Homeworks homeworks={homeworks} />
|
<GradesStatsCircle grades={grades} />
|
||||||
<SpecificEvaluations specificEvaluations={specificEvaluations} />
|
</div>
|
||||||
<Orientation orientation={orientation} /> */}
|
</div>
|
||||||
</>
|
<div className="flex items-center justify-center">
|
||||||
|
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -107,7 +107,16 @@ export default function StudentCompetenciesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button text="Enregistrer le bilan" primary type="submit" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,7 +27,8 @@ const Button = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={buttonClass} onClick={handleClick} disabled={disabled}>
|
<button className={buttonClass} onClick={handleClick} disabled={disabled}>
|
||||||
{icon && <span className="mr-2">{icon}</span>}
|
{icon && text && <span className="mr-2">{icon}</span>}
|
||||||
|
{icon && !text && icon}
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
export default function Attendance({ absences }) {
|
export default function Attendance({ absences }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
<div className="w-full bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
<h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2>
|
<h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2>
|
||||||
<ol className="relative border-l border-emerald-200">
|
<ol className="relative border-l border-emerald-200">
|
||||||
{absences.map((absence, idx) => (
|
{absences.map((absence, idx) => (
|
||||||
|
|||||||
65
Front-End/src/components/Grades/GradesDomainBarChart.js
Normal file
65
Front-End/src/components/Grades/GradesDomainBarChart.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function GradesDomainBarChart({ studentCompetencies }) {
|
||||||
|
if (!studentCompetencies?.data) return null;
|
||||||
|
|
||||||
|
// Calcul du score moyen par domaine
|
||||||
|
const domainStats = studentCompetencies.data.map((domaine) => {
|
||||||
|
const allScores = domaine.categories.flatMap(
|
||||||
|
(cat) =>
|
||||||
|
cat.competences
|
||||||
|
.map((comp) => comp.score ?? 0)
|
||||||
|
.filter((score) => score > 0) // Ignorer les notes à 0
|
||||||
|
);
|
||||||
|
const avg =
|
||||||
|
allScores.length > 0
|
||||||
|
? (allScores.reduce((a, b) => a + b, 0) / allScores.length).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
name: domaine.domaine_nom,
|
||||||
|
avg: Number(avg),
|
||||||
|
count: allScores.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Détermine la couleur de la jauge selon la moyenne
|
||||||
|
const getBarGradient = (avg) => {
|
||||||
|
if (avg > 0 && avg <= 1) return 'bg-gradient-to-r from-red-200 to-red-400';
|
||||||
|
if (avg > 1 && avg <= 2)
|
||||||
|
return 'bg-gradient-to-r from-yellow-200 to-yellow-400';
|
||||||
|
if (avg > 2) return 'bg-gradient-to-r from-emerald-200 to-emerald-500';
|
||||||
|
return 'bg-gray-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">Moyenne par domaine</h2>
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
{domainStats.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center w-full">
|
||||||
|
<span className="font-medium text-left" style={{ width: '30%' }}>
|
||||||
|
{d.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center" style={{ width: '40%' }}>
|
||||||
|
<div className="w-full bg-emerald-100 h-3 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded ${getBarGradient(d.avg)}`}
|
||||||
|
style={{ width: `${d.avg * 33.33}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-semibold text-emerald-700 text-left pl-2 flex-shrink-0"
|
||||||
|
style={{ width: '10%' }}
|
||||||
|
>
|
||||||
|
{d.avg}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-left" style={{ width: '20%' }}>
|
||||||
|
({`compétences évaluées ${d.count}`})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ export default function GradesStatsCircle({ grades }) {
|
|||||||
const percent = total ? Math.round((acquired / total) * 100) : 0;
|
const percent = total ? Math.round((acquired / total) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-4 bg-white 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">Statistiques globales</h2>
|
<h2 className="text-xl font-semibold mb-2">Statistiques globales</h2>
|
||||||
<div style={{ width: 120, height: 120 }}>
|
<div style={{ width: 120, height: 120 }}>
|
||||||
<CircularProgressbar
|
<CircularProgressbar
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { BASE_URL } from '@/utils/Url';
|
||||||
|
|
||||||
export default function StudentInput({
|
export default function StudentInput({
|
||||||
@ -13,6 +13,18 @@ export default function StudentInput({
|
|||||||
const [suggestions, setSuggestions] = useState([]);
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
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 handleInputChange = async (e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
@ -31,9 +43,7 @@ export default function StudentInput({
|
|||||||
|
|
||||||
const handleSuggestionClick = (student) => {
|
const handleSuggestionClick = (student) => {
|
||||||
setSelectedStudent(student);
|
setSelectedStudent(student);
|
||||||
setInputValue(
|
setInputValue(`${student.last_name} ${student.first_name}`);
|
||||||
`${student.last_name} ${student.first_name} (${student.level}) - ${student.associated_class_name}`
|
|
||||||
);
|
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user