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:
@ -11,7 +11,10 @@ import Orientation from '@/components/Grades/Orientation';
|
||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||
import Button from '@/components/Button';
|
||||
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 {
|
||||
fetchStudents,
|
||||
@ -21,6 +24,9 @@ import {
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
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() {
|
||||
const router = useRouter();
|
||||
@ -145,9 +151,14 @@ export default function Page() {
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Sélection de l'élève */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h2 className="text-xl font-semibold mb-4">Sélectionner un élève</h2>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
<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">
|
||||
<div className="flex-1">
|
||||
<StudentInput
|
||||
label="Recherche élève"
|
||||
@ -161,49 +172,78 @@ export default function Page() {
|
||||
establishmentId={selectedEstablishmentId}
|
||||
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>
|
||||
<Button
|
||||
text="Réaliser le bilan de compétences"
|
||||
primary
|
||||
disabled={!formData.selectedStudent}
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
|
||||
router.push(`${url}`);
|
||||
}}
|
||||
className={`px-6 py-2 rounded-md shadow ${
|
||||
!formData.selectedStudent
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
/>
|
||||
{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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
|
||||
{formData.selectedStudent && (
|
||||
<>
|
||||
{/* <AcademicResults results={academicResults} /> */}
|
||||
<Attendance absences={absences} />
|
||||
<GradesStatsCircle grades={grades} />
|
||||
{/* <Remarks remarks={remarks} />
|
||||
<WorkPlan workPlan={workPlan} />
|
||||
<Homeworks homeworks={homeworks} />
|
||||
<SpecificEvaluations specificEvaluations={specificEvaluations} />
|
||||
<Orientation orientation={orientation} /> */}
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -107,7 +107,16 @@ export default function StudentCompetenciesPage() {
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,8 @@ const Button = ({
|
||||
|
||||
return (
|
||||
<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}
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
export default function Attendance({ absences }) {
|
||||
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>
|
||||
<ol className="relative border-l border-emerald-200">
|
||||
{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;
|
||||
|
||||
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>
|
||||
<div style={{ width: 120, height: 120 }}>
|
||||
<CircularProgressbar
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
|
||||
export default function StudentInput({
|
||||
@ -13,6 +13,18 @@ export default function StudentInput({
|
||||
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);
|
||||
@ -31,9 +43,7 @@ export default function StudentInput({
|
||||
|
||||
const handleSuggestionClick = (student) => {
|
||||
setSelectedStudent(student);
|
||||
setInputValue(
|
||||
`${student.last_name} ${student.first_name} (${student.level}) - ${student.associated_class_name}`
|
||||
);
|
||||
setInputValue(`${student.last_name} ${student.first_name}`);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user