diff --git a/Back-End/School/views.py b/Back-End/School/views.py index 7ca25eb..9a1531e 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -73,12 +73,15 @@ class SpecialityListCreateView(APIView): def get(self, request): establishment_id = request.GET.get('establishment_id', None) + school_year = request.GET.get('school_year', None) if establishment_id is None: return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) specialities_list = getAllObjects(Speciality) if establishment_id: specialities_list = specialities_list.filter(establishment__id=establishment_id).distinct() + if school_year: + specialities_list = specialities_list.filter(school_year=school_year) specialities_serializer = SpecialitySerializer(specialities_list, many=True) return JsonResponse(specialities_serializer.data, safe=False) diff --git a/Back-End/Subscriptions/serializers.py b/Back-End/Subscriptions/serializers.py index a91f6ae..12257a2 100644 --- a/Back-End/Subscriptions/serializers.py +++ b/Back-End/Subscriptions/serializers.py @@ -399,10 +399,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer): class StudentByParentSerializer(serializers.ModelSerializer): id = serializers.IntegerField(required=False) associated_class_name = serializers.SerializerMethodField() + associated_class_id = serializers.SerializerMethodField() class Meta: model = Student - fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name'] + fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name', 'associated_class_id'] def __init__(self, *args, **kwargs): super(StudentByParentSerializer, self).__init__(*args, **kwargs) @@ -412,6 +413,9 @@ class StudentByParentSerializer(serializers.ModelSerializer): def get_associated_class_name(self, obj): return obj.associated_class.atmosphere_name if obj.associated_class else None + def get_associated_class_id(self, obj): + return obj.associated_class.id if obj.associated_class else None + class RegistrationFormByParentSerializer(serializers.ModelSerializer): student = StudentByParentSerializer(many=False, required=True) diff --git a/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js b/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js index a8ee078..43ef6fa 100644 --- a/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js +++ b/Front-End/src/app/[locale]/admin/structure/SchoolClassManagement/page.js @@ -205,10 +205,12 @@ export default function Page() { } }, [filteredStudents, fetchedAbsences]); - // Load specialities for evaluations + // Load specialities for evaluations (filtered by current school year) useEffect(() => { if (selectedEstablishmentId) { - fetchSpecialities(selectedEstablishmentId) + const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; + const currentSchoolYear = `${year}-${year + 1}`; + fetchSpecialities(selectedEstablishmentId, currentSchoolYear) .then((data) => setSpecialities(data)) .catch((error) => logger.error('Erreur lors du chargement des matières:', error)); } diff --git a/Front-End/src/app/[locale]/parents/page.js b/Front-End/src/app/[locale]/parents/page.js index 210b8a5..42d497f 100644 --- a/Front-End/src/app/[locale]/parents/page.js +++ b/Front-End/src/app/[locale]/parents/page.js @@ -1,7 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import Table from '@/components/Table'; import { Edit3, Users, @@ -9,6 +8,12 @@ import { Eye, Upload, CalendarDays, + Award, + ChevronDown, + ChevronUp, + BookOpen, + ArrowLeft, + Clock, } from 'lucide-react'; import StatusLabel from '@/components/StatusLabel'; import FileUpload from '@/components/Form/FileUpload'; @@ -16,7 +21,13 @@ import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url'; import { fetchChildren, editRegisterForm, + fetchStudentCompetencies, + fetchAbsences, } from '@/app/actions/subscriptionAction'; +import { + fetchEvaluations, + fetchStudentEvaluations, +} from '@/app/actions/schoolAction'; import { fetchUpcomingEvents } from '@/app/actions/planningAction'; import logger from '@/utils/logger'; import { getSecureFileUrl } from '@/utils/fileUrl'; @@ -27,13 +38,47 @@ import { PlanningProvider, PlanningModes } from '@/context/PlanningContext'; import SectionHeader from '@/components/SectionHeader'; import ParentPlanningSection from '@/components/ParentPlanningSection'; import EventCard from '@/components/EventCard'; +import SelectChoice from '@/components/Form/SelectChoice'; +import dayjs from 'dayjs'; + +// Fonction utilitaire pour générer la chaîne de période +function getPeriodString(selectedPeriod, frequency) { + const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; + const nextYear = (year + 1).toString(); + const schoolYear = `${year}-${nextYear}`; + if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`; + if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`; + if (frequency === 3) return `A_${schoolYear}`; + return ''; +} + +// Fonction pour obtenir les périodes selon la fréquence d'évaluation +function getPeriods(frequency) { + if (frequency === 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 (frequency === 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 (frequency === 3) { + return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }]; + } + return []; +} export default function ParentHomePage() { const [children, setChildren] = useState([]); - const { user, selectedEstablishmentId } = useEstablishment(); - const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload - const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé - const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant + const { user, selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment(); + const [uploadingStudentId, setUploadingStudentId] = useState(null); + const [uploadedFile, setUploadedFile] = useState(null); + const [uploadState, setUploadState] = useState('off'); const [showPlanning, setShowPlanning] = useState(false); const [planningClassName, setPlanningClassName] = useState(null); const [upcomingEvents, setUpcomingEvents] = useState([]); @@ -42,16 +87,114 @@ export default function ParentHomePage() { const [reloadFetch, setReloadFetch] = useState(false); const { getNiveauLabel } = useClasses(); + // États pour la vue détaillée de l'élève inscrit + const [expandedStudentId, setExpandedStudentId] = useState(null); + const [studentCompetencies, setStudentCompetencies] = useState(null); + const [grades, setGrades] = useState({}); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [evaluations, setEvaluations] = useState([]); + const [studentEvaluationsData, setStudentEvaluationsData] = useState([]); + const [allAbsences, setAllAbsences] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + // Périodes disponibles selon la fréquence d'évaluation + const periods = useMemo( + () => getPeriods(selectedEstablishmentEvaluationFrequency), + [selectedEstablishmentEvaluationFrequency] + ); + + // Auto-sélection de la période courante + useEffect(() => { + if (periods.length > 0 && !selectedPeriod) { + 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 : periods[0]?.value); + } + }, [periods, selectedPeriod]); + useEffect(() => { if (user !== null) { const userIdFromSession = user.user_id; fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => { setChildren(data); + // Auto-expand si un seul enfant inscrit + const enrolledChildren = (data || []).filter((c) => c.status === 5); + if (enrolledChildren.length === 1) { + setExpandedStudentId(enrolledChildren[0].student.id); + } }); setReloadFetch(false); } }, [selectedEstablishmentId, reloadFetch, user]); + // Charger les absences + useEffect(() => { + if (selectedEstablishmentId) { + fetchAbsences(selectedEstablishmentId) + .then((data) => setAllAbsences(data || [])) + .catch((error) => logger.error('Erreur fetch absences:', error)); + } + }, [selectedEstablishmentId]); + + // Charger les données détaillées quand un élève est étendu + useEffect(() => { + if (!expandedStudentId || !selectedPeriod || !selectedEstablishmentEvaluationFrequency) { + return; + } + + const expandedChild = children.find((c) => c.student.id === expandedStudentId); + if (!expandedChild || expandedChild.status !== 5) return; + + const loadDetails = async () => { + setDetailLoading(true); + const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency); + + try { + // Charger les compétences + const competenciesData = await fetchStudentCompetencies(expandedStudentId, periodString); + setStudentCompetencies(competenciesData); + if (competenciesData?.data) { + const initialGrades = {}; + competenciesData.data.forEach((domaine) => { + domaine.categories.forEach((cat) => { + cat.competences.forEach((comp) => { + initialGrades[comp.competence_id] = comp.score ?? 0; + }); + }); + }); + setGrades(initialGrades); + } + + // Charger les évaluations si l'élève a une classe + if (expandedChild.student.associated_class_id) { + const [evalData, studentEvalData] = await Promise.all([ + fetchEvaluations( + selectedEstablishmentId, + expandedChild.student.associated_class_id, + periodString + ), + fetchStudentEvaluations(expandedStudentId, null, periodString, null) + ]); + setEvaluations(evalData || []); + setStudentEvaluationsData(studentEvalData || []); + } else { + setEvaluations([]); + setStudentEvaluationsData([]); + } + } catch (error) { + logger.error('Erreur lors du chargement des détails:', error); + } finally { + setDetailLoading(false); + } + }; + + loadDetails(); + }, [expandedStudentId, selectedPeriod, selectedEstablishmentEvaluationFrequency, children, selectedEstablishmentId]); + useEffect(() => { if (selectedEstablishmentId) { // Fetch des événements à venir @@ -132,153 +275,6 @@ export default function ParentHomePage() { setShowPlanning(true); }; - const childrenColumns = [ - { - name: 'photo', - transform: (row) => ( -
- {row.student.photo ? ( - - {`${row.student.first_name} - - ) : ( -
- - {row.student.first_name[0]} - {row.student.last_name[0]} - -
- )} -
- ), - }, - { name: 'Nom', transform: (row) => `${row.student.last_name}` }, - { name: 'Prénom', transform: (row) => `${row.student.first_name}` }, - { - name: 'Classe', - transform: (row) => ( -
{row.student.associated_class_name}
- ), - }, - { - name: 'Niveau', - transform: (row) => ( -
{getNiveauLabel(row.student.level)}
- ), - }, - { - name: 'Statut', - transform: (row) => ( -
- -
- ), - }, - { - name: 'Actions', - transform: (row) => ( -
- {row.status === 2 && ( - - )} - - {(row.status === 3 || row.status === 8) && ( - - )} - - {row.status === 7 && ( - <> - - - - - - - )} - - {row.status === 5 && ( - <> - - - - )} -
- ), - }, - ]; - return (
{showPlanning && planningClassName ? ( @@ -326,29 +322,375 @@ export default function ParentHomePage() { title="Vos enfants" description="Suivez le parcours de vos enfants" /> -
- + + {/* Cartes des enfants */} +
+ {children.map((child) => { + const student = child.student; + const isEnrolled = child.status === 5; + const isExpanded = expandedStudentId === student.id; + + // Absences pour cet élève (détaillées par type) + const studentAbsencesList = allAbsences.filter((a) => a.student === student.id); + const absenceStats = { + justifiedAbsence: studentAbsencesList.filter((a) => a.reason === 1).length, + unjustifiedAbsence: studentAbsencesList.filter((a) => a.reason === 2).length, + justifiedLate: studentAbsencesList.filter((a) => a.reason === 3).length, + unjustifiedLate: studentAbsencesList.filter((a) => a.reason === 4).length, + }; + const totalAbsences = absenceStats.justifiedAbsence + absenceStats.unjustifiedAbsence; + + return ( +
+ {/* En-tête de la carte (toujours visible) */} +
{ + if (isEnrolled) { + setExpandedStudentId(isExpanded ? null : student.id); + } + }} + > + {/* Photo */} +
+ {student.photo ? ( + {`${student.first_name} + ) : ( +
+ {student.first_name?.[0]}{student.last_name?.[0]} +
+ )} +
+ + {/* Infos principales */} +
+

+ {student.last_name} {student.first_name} +

+
+ +
+
+ {student.associated_class_name && ( + Classe : {student.associated_class_name} + )} + {student.level !== undefined && ( + Niveau : {getNiveauLabel(student.level)} + )} +
+ {isEnrolled && ( +
+ + Cliquez pour voir le suivi pédagogique +
+ )} +
+ + {/* Actions */} +
+ {child.status === 2 && ( + + )} + + {(child.status === 3 || child.status === 8 || child.status === 5 || child.status === 7) && ( + + )} + + {child.status === 7 && ( + <> + e.stopPropagation()} + title="Télécharger le mandat SEPA" + > + + + + + )} + + {isEnrolled && ( + <> + +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + )} +
+
+ + {/* Upload SEPA si activé */} + {uploadState === 'on' && uploadingStudentId === student.id && ( +
+ + +
+ )} + + {/* Section détaillée pour les élèves inscrits (expanded) */} + {isEnrolled && isExpanded && ( +
+ + {/* Bloc période : compétences + notes */} +
+ {/* Sélecteur de période */} +
+
+ ({ + value: period.value, + label: period.label, + }))} + selected={selectedPeriod || ''} + callback={(e) => setSelectedPeriod(Number(e.target.value))} + /> +
+
+ + {detailLoading ? ( +
+ Chargement des données... +
+ ) : ( + <> + {/* Résumé des compétences (pourcentages) */} + {(() => { + const total = Object.keys(grades).length; + const acquired = Object.values(grades).filter((g) => g === 3).length; + const inProgress = Object.values(grades).filter((g) => g === 2).length; + const notAcquired = Object.values(grades).filter((g) => g === 1).length; + const notEvaluated = Object.values(grades).filter((g) => g === 0).length; + + const pctAcquired = total ? Math.round((acquired / total) * 100) : 0; + const pctInProgress = total ? Math.round((inProgress / total) * 100) : 0; + const pctNotAcquired = total ? Math.round((notAcquired / total) * 100) : 0; + const pctNotEvaluated = total ? Math.round((notEvaluated / total) * 100) : 0; + + return ( +
+
+ +

+ Compétences +

+ {total > 0 && ( + ({total} compétences) + )} +
+ {total > 0 ? ( +
+
+ {pctAcquired}% + Acquises +
+
+ {pctInProgress}% + En cours +
+
+ {pctNotAcquired}% + Non acquises +
+
+ {pctNotEvaluated}% + Non évaluées +
+
+ ) : ( +

Aucune compétence évaluée pour cette période.

+ )} +
+ ); + })()} + + {/* Notes par matière - Vue simplifiée */} +
+
+ +

+ Notes par matière +

+
+ {evaluations.length > 0 ? ( +
+ {(() => { + // Grouper par matière + const bySpeciality = evaluations.reduce((acc, ev) => { + const key = ev.speciality_name || 'Sans matière'; + if (!acc[key]) { + acc[key] = { + name: key, + color: ev.speciality_color || '#6B7280', + evaluations: [], + totalWeighted: 0, + totalCoef: 0, + }; + } + const studentEval = studentEvaluationsData.find((se) => se.evaluation === ev.id); + acc[key].evaluations.push({ ...ev, studentScore: studentEval?.score, isAbsent: studentEval?.is_absent }); + if (studentEval?.score != null && !studentEval?.is_absent) { + const normalized = (studentEval.score / ev.max_score) * 20; + acc[key].totalWeighted += normalized * ev.coefficient; + acc[key].totalCoef += parseFloat(ev.coefficient); + } + return acc; + }, {}); + + return Object.values(bySpeciality).map((group) => { + const avg = group.totalCoef > 0 ? (group.totalWeighted / group.totalCoef) : null; + const evalCount = group.evaluations.length; + const gradedCount = group.evaluations.filter((e) => e.studentScore != null).length; + + return ( +
+
+ + {group.name} +
+
+ + {avg !== null ? avg.toFixed(1) : '-'} + + /20 +
+
+ {gradedCount}/{evalCount} évaluation{evalCount > 1 ? 's' : ''} +
+
+ ); + }); + })()} +
+ ) : ( +

Aucune évaluation pour cette période.

+ )} +
+ + )} +
+ {/* Fin bloc période */} + + {/* Section Absences — toute l'année scolaire */} +
+
+ +

+ Absences & Retards +

+
+

Toute l'année scolaire

+
+
+ {absenceStats.justifiedAbsence} + Absences justifiées +
+
+ {absenceStats.unjustifiedAbsence} + Absences non justifiées +
+
+ {absenceStats.justifiedLate} + Retards justifiés +
+
+ {absenceStats.unjustifiedLate} + Retards non justifiés +
+
+
+ +
+ )} +
+ ); + })}
- {/* Composant FileUpload et bouton Valider en dessous du tableau */} - {uploadState === 'on' && uploadingStudentId && ( -
- - -
- )} )} diff --git a/Front-End/src/app/actions/schoolAction.js b/Front-End/src/app/actions/schoolAction.js index 7028882..651d587 100644 --- a/Front-End/src/app/actions/schoolAction.js +++ b/Front-End/src/app/actions/schoolAction.js @@ -37,10 +37,10 @@ export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => { ); }; -export const fetchSpecialities = (establishment) => { - return fetchWithAuth( - `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}` - ); +export const fetchSpecialities = (establishment, schoolYear = null) => { + let url = `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`; + if (schoolYear) url += `&school_year=${schoolYear}`; + return fetchWithAuth(url); }; export const fetchTeachers = (establishment) => {