diff --git a/Back-End/School/models.py b/Back-End/School/models.py index 35db647..f03a19a 100644 --- a/Back-End/School/models.py +++ b/Back-End/School/models.py @@ -21,6 +21,7 @@ class Speciality(models.Model): name = models.CharField(max_length=100) updated_date = models.DateTimeField(auto_now=True) color_code = models.CharField(max_length=7, default='#FFFFFF') + school_year = models.CharField(max_length=9, blank=True) establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='specialities') def __str__(self): @@ -31,6 +32,7 @@ class Teacher(models.Model): first_name = models.CharField(max_length=100) specialities = models.ManyToManyField(Speciality, blank=True) profile_role = models.OneToOneField('Auth.ProfileRole', on_delete=models.CASCADE, related_name='teacher_profile', null=True, blank=True) + school_year = models.CharField(max_length=9, blank=True) updated_date = models.DateTimeField(auto_now=True) def __str__(self): @@ -48,6 +50,7 @@ class SchoolClass(models.Model): number_of_students = models.PositiveIntegerField(null=True, blank=True) teaching_language = models.CharField(max_length=255, blank=True) school_year = models.CharField(max_length=9, blank=True) + created_at = models.DateTimeField(auto_now_add=True, null=True) updated_date = models.DateTimeField(auto_now=True) teachers = models.ManyToManyField(Teacher, blank=True) levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes') diff --git a/Back-End/School/urls.py b/Back-End/School/urls.py index 641d830..6c32bc3 100644 --- a/Back-End/School/urls.py +++ b/Back-End/School/urls.py @@ -13,6 +13,7 @@ from .views import ( EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView, EvaluationListCreateView, EvaluationDetailView, StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView, + SchoolYearsListView, ) urlpatterns = [ @@ -54,4 +55,7 @@ urlpatterns = [ re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"), re_path(r'^studentEvaluations/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"), re_path(r'^studentEvaluations/(?P[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"), + + # History / School Years + re_path(r'^schoolYears$', SchoolYearsListView.as_view(), name="school_years_list"), ] \ No newline at end of file diff --git a/Back-End/School/views.py b/Back-End/School/views.py index 8c41d99..7ca25eb 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -38,12 +38,34 @@ from N3wtSchool.bdd import delete_object, getAllObjects, getObject from django.db.models import Q from collections import defaultdict from Subscriptions.models import Student, StudentCompetency, StudentEvaluation -from Subscriptions.util import getCurrentSchoolYear +from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears import logging from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher logger = logging.getLogger(__name__) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SchoolYearsListView(APIView): + """ + Liste les années scolaires disponibles pour l'historique. + Retourne l'année en cours, la suivante, et les années historiques. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + current_year = getCurrentSchoolYear() + next_year = getNextSchoolYear() + historical_years = getHistoricalYears(5) + + return JsonResponse({ + 'current_year': current_year, + 'next_year': next_year, + 'historical_years': historical_years, + 'all_years': [next_year, current_year] + historical_years + }, safe=False) + @method_decorator(csrf_protect, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch') class SpecialityListCreateView(APIView): @@ -186,12 +208,33 @@ class SchoolClassListCreateView(APIView): def get(self, request): establishment_id = request.GET.get('establishment_id', None) + school_year = request.GET.get('school_year', None) + year_filter = request.GET.get('year_filter', None) # 'current_year', 'next_year', 'historical' + if establishment_id is None: return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) school_classes_list = getAllObjects(SchoolClass) if school_classes_list: - school_classes_list = school_classes_list.filter(establishment=establishment_id).distinct() + school_classes_list = school_classes_list.filter(establishment=establishment_id) + + # Filtrage par année scolaire + if school_year: + school_classes_list = school_classes_list.filter(school_year=school_year) + elif year_filter: + current_year = getCurrentSchoolYear() + next_year = getNextSchoolYear() + historical_years = getHistoricalYears(5) + + if year_filter == 'current_year': + school_classes_list = school_classes_list.filter(school_year=current_year) + elif year_filter == 'next_year': + school_classes_list = school_classes_list.filter(school_year=next_year) + elif year_filter == 'historical': + school_classes_list = school_classes_list.filter(school_year__in=historical_years) + + school_classes_list = school_classes_list.distinct() + classes_serializer = SchoolClassSerializer(school_classes_list, many=True) return JsonResponse(classes_serializer.data, safe=False) diff --git a/Back-End/Subscriptions/models.py b/Back-End/Subscriptions/models.py index 7c216ce..ce5ff6c 100644 --- a/Back-End/Subscriptions/models.py +++ b/Back-End/Subscriptions/models.py @@ -130,6 +130,10 @@ class Student(models.Model): # One-to-Many Relationship associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students') + # Audit fields + created_at = models.DateTimeField(auto_now_add=True, null=True) + updated_at = models.DateTimeField(auto_now=True, null=True) + def __str__(self): return self.last_name + "_" + self.first_name @@ -252,6 +256,7 @@ class RegistrationForm(models.Model): # One-to-One Relationship student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True) status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE) + created_at = models.DateTimeField(auto_now_add=True, null=True) last_update = models.DateTimeField(auto_now=True) school_year = models.CharField(max_length=9, default="", blank=True) notes = models.CharField(max_length=200, blank=True) @@ -578,6 +583,8 @@ class StudentCompetency(models.Model): default="", blank=True ) + created_at = models.DateTimeField(auto_now_add=True, null=True) + updated_at = models.DateTimeField(auto_now=True, null=True) class Meta: unique_together = ('student', 'establishment_competency', 'period') diff --git a/Back-End/Subscriptions/views/student_views.py b/Back-End/Subscriptions/views/student_views.py index 6a43ba9..d04bc68 100644 --- a/Back-End/Subscriptions/views/student_views.py +++ b/Back-End/Subscriptions/views/student_views.py @@ -54,6 +54,12 @@ class StudentListView(APIView): description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True + ), + openapi.Parameter( + 'school_year', openapi.IN_QUERY, + description="Année scolaire (ex: 2025-2026)", + type=openapi.TYPE_STRING, + required=False ) ] ) @@ -61,6 +67,7 @@ class StudentListView(APIView): def get(self, request): establishment_id = request.GET.get('establishment_id', None) status_filter = request.GET.get('status', None) # Nouveau filtre optionnel + school_year_filter = request.GET.get('school_year', None) # Filtre année scolaire if establishment_id is None: return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) @@ -70,6 +77,9 @@ class StudentListView(APIView): if status_filter: students_qs = students_qs.filter(registrationform__status=status_filter) + if school_year_filter: + students_qs = students_qs.filter(registrationform__school_year=school_year_filter) + students_qs = students_qs.distinct() students_serializer = StudentByRFCreationSerializer(students_qs, many=True) return JsonResponse(students_serializer.data, safe=False) diff --git a/Front-End/src/app/[locale]/admin/grades/page.js b/Front-End/src/app/[locale]/admin/grades/page.js index 906127c..c3416d3 100644 --- a/Front-End/src/app/[locale]/admin/grades/page.js +++ b/Front-End/src/app/[locale]/admin/grades/page.js @@ -1,5 +1,5 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { Award, @@ -10,6 +10,7 @@ import { Pencil, Trash2, Save, + Download } from 'lucide-react'; import SectionHeader from '@/components/SectionHeader'; import Table from '@/components/Table'; @@ -32,14 +33,20 @@ import { import { useEstablishment } from '@/context/EstablishmentContext'; import { useClasses } from '@/context/ClassesContext'; import { useCsrfToken } from '@/context/CsrfContext'; +import { exportToCSV } from '@/utils/exportCSV'; +import SchoolYearFilter from '@/components/SchoolYearFilter'; +import { getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears } from '@/utils/Date'; +import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants'; import dayjs from 'dayjs'; -function getPeriodString(periodValue, frequency) { - const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; - const schoolYear = `${year}-${year + 1}`; - if (frequency === 1) return `T${periodValue}_${schoolYear}`; - if (frequency === 2) return `S${periodValue}_${schoolYear}`; - if (frequency === 3) return `A_${schoolYear}`; +function getPeriodString(periodValue, frequency, schoolYear = null) { + const year = schoolYear || (() => { + const y = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; + return `${y}-${y + 1}`; + })(); + if (frequency === 1) return `T${periodValue}_${year}`; + if (frequency === 2) return `S${periodValue}_${year}`; + if (frequency === 3) return `A_${year}`; return ''; } @@ -169,17 +176,33 @@ export default function Page() { const [editingEvalId, setEditingEvalId] = useState(null); const [editScore, setEditScore] = useState(''); const [editAbsent, setEditAbsent] = useState(false); + + // Filtrage par année scolaire + const [activeYearFilter, setActiveYearFilter] = useState(CURRENT_YEAR_FILTER); + const currentSchoolYear = useMemo(() => getCurrentSchoolYear(), []); + const nextSchoolYear = useMemo(() => getNextSchoolYear(), []); + const historicalYears = useMemo(() => getHistoricalYears(5), []); + + // Déterminer l'année scolaire sélectionnée + const selectedSchoolYear = useMemo(() => { + if (activeYearFilter === CURRENT_YEAR_FILTER) return currentSchoolYear; + if (activeYearFilter === NEXT_YEAR_FILTER) return nextSchoolYear; + // Pour l'historique, on utilise la première année historique par défaut + // L'utilisateur pourra choisir une année spécifique si nécessaire + return historicalYears[0]; + }, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]); - const periodColumns = getPeriodColumns( + const periodColumns = useMemo(() => getPeriodColumns( selectedEstablishmentEvaluationFrequency - ); + ), [selectedEstablishmentEvaluationFrequency]); + const currentPeriodValue = getCurrentPeriodValue( selectedEstablishmentEvaluationFrequency ); useEffect(() => { if (!selectedEstablishmentId) return; - fetchStudents(selectedEstablishmentId, null, 5) + fetchStudents(selectedEstablishmentId, null, 5, selectedSchoolYear) .then((data) => setStudents(data)) .catch((error) => logger.error('Error fetching students:', error)); @@ -194,7 +217,7 @@ export default function Page() { setAbsencesMap(map); }) .catch((error) => logger.error('Error fetching absences:', error)); - }, [selectedEstablishmentId]); + }, [selectedEstablishmentId, selectedSchoolYear]); // Fetch stats for all students - aggregate all periods useEffect(() => { @@ -205,7 +228,7 @@ export default function Page() { const tasks = students.flatMap((student) => periodColumns.map(({ value: periodValue }) => { - const periodStr = getPeriodString(periodValue, frequency); + const periodStr = getPeriodString(periodValue, frequency, selectedSchoolYear); return fetchStudentCompetencies(student.id, periodStr) .then((data) => ({ studentId: student.id, periodValue, data })) .catch(() => ({ studentId: student.id, periodValue, data: null })); @@ -257,7 +280,7 @@ export default function Page() { setStatsMap(map); setStatsLoading(false); }); - }, [students, selectedEstablishmentEvaluationFrequency]); + }, [students, selectedEstablishmentEvaluationFrequency, selectedSchoolYear, periodColumns]); const filteredStudents = students.filter( (student) => @@ -299,6 +322,38 @@ export default function Page() { setEditingEvalId(null); }; + // Export CSV + const handleExportCSV = () => { + const exportColumns = [ + { key: 'id', label: 'ID' }, + { key: 'last_name', label: 'Nom' }, + { key: 'first_name', label: 'Prénom' }, + { key: 'birth_date', label: 'Date de naissance' }, + { key: 'level', label: 'Niveau', transform: (value) => getNiveauLabel(value) }, + { key: 'associated_class_name', label: 'Classe' }, + { + key: 'id', + label: 'Absences', + transform: (value) => absencesMap[value] || 0 + }, + ]; + + // Ajouter les colonnes de compétences si les stats sont chargées + COMPETENCY_COLUMNS.forEach(({ key, label }) => { + exportColumns.push({ + key: 'id', + label: label, + transform: (value) => { + const stats = statsMap[value]; + return stats?.[key] !== undefined ? `${stats[key]}%` : ''; + } + }); + }); + + const filename = `suivi_eleves_${selectedSchoolYear}_${new Date().toISOString().split('T')[0]}`; + exportToCSV(filteredStudents, exportColumns, filename); + }; + const startEditingEval = (evalItem) => { setEditingEvalId(evalItem.id); setEditScore(evalItem.score ?? ''); @@ -372,7 +427,8 @@ export default function Page() { e.stopPropagation(); const periodStr = getPeriodString( currentPeriodValue, - selectedEstablishmentEvaluationFrequency + selectedEstablishmentEvaluationFrequency, + selectedSchoolYear ); router.push( `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}` @@ -509,18 +565,34 @@ export default function Page() { title="Suivi pédagogique" description="Suivez le parcours d'un élève" /> -
- - setSearchTerm(e.target.value)} - /> + +
+
+ + setSearchTerm(e.target.value)} + /> +
+
{ + const dataToExport = activeTab === CURRENT_YEAR_FILTER + ? registrationFormsDataCurrentYear + : activeTab === NEXT_YEAR_FILTER + ? registrationFormsDataNextYear + : registrationFormsDataHistorical; + + const exportColumns = [ + { key: 'student', label: 'Nom', transform: (value) => value?.last_name || '' }, + { key: 'student', label: 'Prénom', transform: (value) => value?.first_name || '' }, + { key: 'student', label: 'Date de naissance', transform: (value) => value?.birth_date || '' }, + { key: 'student', label: 'Email contact', transform: (value) => value?.guardians?.[0]?.associated_profile_email || '' }, + { key: 'student', label: 'Téléphone contact', transform: (value) => value?.guardians?.[0]?.phone || '' }, + { key: 'student', label: 'Nom responsable 1', transform: (value) => value?.guardians?.[0]?.last_name || '' }, + { key: 'student', label: 'Prénom responsable 1', transform: (value) => value?.guardians?.[0]?.first_name || '' }, + { key: 'student', label: 'Nom responsable 2', transform: (value) => value?.guardians?.[1]?.last_name || '' }, + { key: 'student', label: 'Prénom responsable 2', transform: (value) => value?.guardians?.[1]?.first_name || '' }, + { key: 'school_year', label: 'Année scolaire' }, + { key: 'status', label: 'Statut', transform: (value) => { + const statusMap = { + 0: 'En attente', + 1: 'En cours', + 2: 'Envoyé', + 3: 'À relancer', + 4: 'À valider', + 5: 'Validé', + 6: 'Archivé', + }; + return statusMap[value] || value; + }}, + { key: 'formatted_last_update', label: 'Dernière mise à jour' }, + ]; + + const yearLabel = activeTab === CURRENT_YEAR_FILTER + ? currentSchoolYear + : activeTab === NEXT_YEAR_FILTER + ? nextSchoolYear + : 'historique'; + const filename = `inscriptions_${yearLabel}_${new Date().toISOString().split('T')[0]}`; + exportToCSV(dataToExport, exportColumns, filename); + }; + const requestErrorHandler = (err) => { logger.error('Error fetching data:', err); }; @@ -853,17 +898,27 @@ export default function Page({ params: { locale } }) { onChange={handleSearchChange} /> - {profileRole !== 0 && ( +
- )} + {profileRole !== 0 && ( + + )} +
diff --git a/Front-End/src/app/actions/schoolAction.js b/Front-End/src/app/actions/schoolAction.js index ff8c4c5..7028882 100644 --- a/Front-End/src/app/actions/schoolAction.js +++ b/Front-End/src/app/actions/schoolAction.js @@ -11,6 +11,7 @@ import { BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, BE_SCHOOL_EVALUATIONS_URL, BE_SCHOOL_STUDENT_EVALUATIONS_URL, + BE_SCHOOL_SCHOOL_YEARS_URL, } from '@/utils/Url'; import { fetchWithAuth } from '@/utils/fetchWithAuth'; @@ -46,10 +47,15 @@ export const fetchTeachers = (establishment) => { return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`); }; -export const fetchClasses = (establishment) => { - return fetchWithAuth( - `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}` - ); +export const fetchClasses = (establishment, options = {}) => { + let url = `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`; + if (options.schoolYear) url += `&school_year=${options.schoolYear}`; + if (options.yearFilter) url += `&year_filter=${options.yearFilter}`; + return fetchWithAuth(url); +}; + +export const fetchSchoolYears = () => { + return fetchWithAuth(BE_SCHOOL_SCHOOL_YEARS_URL); }; export const fetchClasse = (id) => { diff --git a/Front-End/src/app/actions/subscriptionAction.js b/Front-End/src/app/actions/subscriptionAction.js index 9ee3a60..3afba32 100644 --- a/Front-End/src/app/actions/subscriptionAction.js +++ b/Front-End/src/app/actions/subscriptionAction.js @@ -124,7 +124,7 @@ export const searchStudents = (establishmentId, query) => { return fetchWithAuth(url); }; -export const fetchStudents = (establishment, id = null, status = null) => { +export const fetchStudents = (establishment, id = null, status = null, schoolYear = null) => { let url; if (id) { url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`; @@ -133,6 +133,9 @@ export const fetchStudents = (establishment, id = null, status = null) => { if (status) { url += `&status=${status}`; } + if (schoolYear) { + url += `&school_year=${encodeURIComponent(schoolYear)}`; + } } return fetchWithAuth(url); }; diff --git a/Front-End/src/components/SchoolYearFilter.js b/Front-End/src/components/SchoolYearFilter.js new file mode 100644 index 0000000..b443b79 --- /dev/null +++ b/Front-End/src/components/SchoolYearFilter.js @@ -0,0 +1,52 @@ +'use client'; +import React from 'react'; +import Tab from '@/components/Tab'; +import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date'; +import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants'; + +/** + * Composant de filtre par année scolaire réutilisable. + * Affiche des tabs pour l'année en cours, l'année prochaine et l'historique. + * + * @param {string} activeFilter - Le filtre actif ('current_year', 'next_year', 'historical') + * @param {function} onFilterChange - Callback appelé quand le filtre change + * @param {object} counts - Objet contenant les compteurs par filtre { currentYear, nextYear, historical } + * @param {boolean} showNextYear - Afficher ou non l'onglet année prochaine (défaut: true) + * @param {boolean} showHistorical - Afficher ou non l'onglet historique (défaut: true) + */ +const SchoolYearFilter = ({ + activeFilter, + onFilterChange, + counts = {}, + showNextYear = true, + showHistorical = true, +}) => { + const currentSchoolYear = getCurrentSchoolYear(); + const nextSchoolYear = getNextSchoolYear(); + + return ( +
+ onFilterChange(CURRENT_YEAR_FILTER)} + /> + {showNextYear && ( + onFilterChange(NEXT_YEAR_FILTER)} + /> + )} + {showHistorical && ( + onFilterChange(HISTORICAL_FILTER)} + /> + )} +
+ ); +}; + +export default SchoolYearFilter; diff --git a/Front-End/src/components/SectionHeader.js b/Front-End/src/components/SectionHeader.js index ad8c79e..9976724 100644 --- a/Front-End/src/components/SectionHeader.js +++ b/Front-End/src/components/SectionHeader.js @@ -9,6 +9,7 @@ const SectionHeader = ({ button = false, buttonOpeningModal = false, onClick = null, + secondaryButton = null, // Bouton secondaire (ex: export) }) => { return (
@@ -29,18 +30,21 @@ const SectionHeader = ({

{description}

- {button && onClick && ( - - )} +
+ {secondaryButton} + {button && onClick && ( + + )} +
); }; diff --git a/Front-End/src/components/Structure/Configuration/ClassesSection.js b/Front-End/src/components/Structure/Configuration/ClassesSection.js index 53d2ca0..24230ca 100644 --- a/Front-End/src/components/Structure/Configuration/ClassesSection.js +++ b/Front-End/src/components/Structure/Configuration/ClassesSection.js @@ -1,5 +1,5 @@ -import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react'; -import React, { useState, useEffect } from 'react'; +import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand, Download } from 'lucide-react'; +import React, { useState, useEffect, useMemo } from 'react'; import Table from '@/components/Table'; import Popup from '@/components/Popup'; import InputText from '@/components/Form/InputText'; @@ -17,6 +17,8 @@ import { usePlanning } from '@/context/PlanningContext'; import { useClasses } from '@/context/ClassesContext'; import { useRouter } from 'next/navigation'; import AlertMessage from '@/components/AlertMessage'; +import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date'; +import { exportToCSV } from '@/utils/exportCSV'; const ItemTypes = { TEACHER: 'teacher', @@ -115,6 +117,7 @@ const TeachersDropZone = ({ const ClassesSection = ({ classes, + allClasses, setClasses, teachers, handleCreate, @@ -132,13 +135,15 @@ const ClassesSection = ({ const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const ITEMS_PER_PAGE = 10; const [currentPage, setCurrentPage] = useState(1); + + // Les classes arrivent déjà filtrées depuis le parent useEffect(() => { setCurrentPage(1); }, [classes]); useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]); const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE); const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const { selectedEstablishmentId, profileRole } = useEstablishment(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); - const { getNiveauxLabels, allNiveaux } = useClasses(); + const { getNiveauxLabels, getNiveauLabel, allNiveaux } = useClasses(); const router = useRouter(); // Fonction pour générer les années scolaires @@ -188,6 +193,30 @@ const ClassesSection = ({ }); }; + // Export CSV + const handleExportCSV = () => { + const exportColumns = [ + { key: 'id', label: 'ID' }, + { key: 'atmosphere_name', label: "Nom d'ambiance" }, + { key: 'age_range', label: "Tranche d'âge" }, + { + key: 'levels', + label: 'Niveaux', + transform: (value) => value ? value.map(l => getNiveauLabel(l)).join(', ') : '' + }, + { key: 'number_of_students', label: 'Capacité max' }, + { key: 'school_year', label: 'Année scolaire' }, + { + key: 'teachers_details', + label: 'Enseignants', + transform: (value) => value ? value.map(t => `${t.last_name} ${t.first_name}`).join(', ') : '' + }, + { key: 'updated_date_formatted', label: 'Date de mise à jour' }, + ]; + const filename = `classes_${new Date().toISOString().split('T')[0]}`; + exportToCSV(classes, exportColumns, filename); + }; + const handleChange = (e) => { const { name, value } = e.target; @@ -559,6 +588,16 @@ const ClassesSection = ({ description="Gérez les classes de votre école" button={profileRole !== 0} onClick={handleAddClass} + secondaryButton={ + + } />
{ + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + const startYear = currentMonth >= 9 ? currentYear : currentYear - 1; + const choices = []; + for (let i = 0; i < 3; i++) { + const year = startYear + i; + choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` }); + } + return choices; + }; // Récupération des messages d'erreur const getError = (field) => { @@ -43,7 +62,20 @@ const SpecialitiesSection = ({ }; const handleAddSpeciality = () => { - setNewSpeciality({ id: Date.now(), name: '', color_code: '' }); + setNewSpeciality({ id: Date.now(), name: '', color_code: '', school_year: selectedSchoolYear }); + }; + + // Export CSV + const handleExportCSV = () => { + const exportColumns = [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Nom' }, + { key: 'color_code', label: 'Code couleur' }, + { key: 'school_year', label: 'Année scolaire' }, + { key: 'updated_date_formatted', label: 'Date de mise à jour' }, + ]; + const filename = `specialites_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`; + exportToCSV(specialities, exportColumns, filename); }; const handleRemoveSpeciality = (id) => { @@ -150,6 +182,20 @@ const SpecialitiesSection = ({ errorMsg={getError('name')} /> ); + case 'ANNÉE SCOLAIRE': + return ( + + ); case 'ACTIONS': return (
@@ -184,6 +230,8 @@ const SpecialitiesSection = ({ switch (column) { case 'LIBELLE': return ; + case 'ANNÉE SCOLAIRE': + return speciality.school_year; case 'MISE A JOUR': return speciality.updated_date_formatted; case 'ACTIONS': @@ -244,6 +292,7 @@ const SpecialitiesSection = ({ const columns = [ { name: 'LIBELLE', label: 'Libellé' }, + { name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' }, { name: 'MISE A JOUR', label: 'Date mise à jour' }, ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []), ]; @@ -257,6 +306,16 @@ const SpecialitiesSection = ({ description="Gérez les spécialités de votre école" button={profileRole !== 0} onClick={handleAddSpeciality} + secondaryButton={ + + } />
{ + const { activeYearFilter, setActiveYearFilter, filterByYear } = useSchoolYearFilter(); + + // Filtrer les données par année scolaire + const filteredSpecialities = useMemo(() => filterByYear(specialities), [specialities, filterByYear]); + const filteredTeachers = useMemo(() => filterByYear(teachers), [teachers, filterByYear]); + const filteredClasses = useMemo(() => filterByYear(classes), [classes, filterByYear]); + + return ( + <> +
+ +
+ {/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */} +
+
+ + handleCreate( + `${BE_SCHOOL_SPECIALITIES_URL}`, + newData, + setSpecialities + ) + } + handleEdit={(id, updatedData) => + handleEdit( + `${BE_SCHOOL_SPECIALITIES_URL}`, + id, + updatedData, + setSpecialities + ) + } + handleDelete={(id) => + handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities) + } + /> +
+
+ + handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers) + } + handleEdit={(id, updatedData) => + handleEdit( + `${BE_SCHOOL_TEACHERS_URL}`, + id, + updatedData, + setTeachers + ) + } + handleDelete={(id) => + handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers) + } + /> +
+
+
+ + handleCreate( + `${BE_SCHOOL_SCHOOLCLASSES_URL}`, + newData, + setClasses + ) + } + handleEdit={(id, updatedData) => + handleEdit( + `${BE_SCHOOL_SCHOOLCLASSES_URL}`, + id, + updatedData, + setClasses + ) + } + handleDelete={(id) => + handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses) + } + /> +
+ + ); +}; + const StructureManagement = ({ specialities, setSpecialities, @@ -23,82 +135,22 @@ const StructureManagement = ({ }) => { return (
- - {/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */} -
-
- - handleCreate( - `${BE_SCHOOL_SPECIALITIES_URL}`, - newData, - setSpecialities - ) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_SPECIALITIES_URL}`, - id, - updatedData, - setSpecialities - ) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities) - } - /> -
-
- - handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_TEACHERS_URL}`, - id, - updatedData, - setTeachers - ) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers) - } - /> -
-
-
- + + - handleCreate( - `${BE_SCHOOL_SCHOOLCLASSES_URL}`, - newData, - setClasses - ) - } - handleEdit={(id, updatedData) => - handleEdit( - `${BE_SCHOOL_SCHOOLCLASSES_URL}`, - id, - updatedData, - setClasses - ) - } - handleDelete={(id) => - handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses) - } + profiles={profiles} + handleCreate={handleCreate} + handleEdit={handleEdit} + handleDelete={handleDelete} /> -
-
+ +
); }; diff --git a/Front-End/src/components/Structure/Configuration/TeachersSection.js b/Front-End/src/components/Structure/Configuration/TeachersSection.js index 3544e83..43e2722 100644 --- a/Front-End/src/components/Structure/Configuration/TeachersSection.js +++ b/Front-End/src/components/Structure/Configuration/TeachersSection.js @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react'; +import { Edit3, Trash2, GraduationCap, Check, X, Hand, Download } from 'lucide-react'; import Table from '@/components/Table'; import Popup from '@/components/Popup'; import ToggleSwitch from '@/components/Form/ToggleSwitch'; +import SelectChoice from '@/components/Form/SelectChoice'; import { DndProvider, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import InputText from '@/components/Form/InputText'; @@ -10,8 +11,10 @@ import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem' import TeacherItem from './TeacherItem'; import logger from '@/utils/logger'; import { useEstablishment } from '@/context/EstablishmentContext'; +import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext'; import SectionHeader from '@/components/SectionHeader'; import AlertMessage from '@/components/AlertMessage'; +import { exportToCSV } from '@/utils/exportCSV'; const ItemTypes = { SPECIALITY: 'speciality', @@ -120,6 +123,7 @@ const SpecialitiesDropZone = ({ const TeachersSection = ({ teachers, + allTeachers, setTeachers, specialities, profiles, @@ -145,6 +149,22 @@ const TeachersSection = ({ const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const { selectedEstablishmentId, profileRole } = useEstablishment(); + const { selectedSchoolYear } = useSchoolYearFilter(); + + // Génère les choix d'année scolaire (année en cours + 2 suivantes) + const getSchoolYearChoices = () => { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + // Si on est avant septembre, l'année scolaire a commencé l'année précédente + const startYear = currentMonth >= 9 ? currentYear : currentYear - 1; + const choices = []; + for (let i = 0; i < 3; i++) { + const year = startYear + i; + choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` }); + } + return choices; + }; // --- UTILS --- @@ -197,6 +217,7 @@ const TeachersSection = ({ associated_profile_email: '', specialities: [], role_type: 0, + school_year: selectedSchoolYear, }); setFormData({ last_name: '', @@ -204,9 +225,34 @@ const TeachersSection = ({ associated_profile_email: '', specialities: [], role_type: 0, + school_year: selectedSchoolYear, }); }; + // Export CSV + const handleExportCSV = () => { + const exportColumns = [ + { key: 'id', label: 'ID' }, + { key: 'last_name', label: 'Nom' }, + { key: 'first_name', label: 'Prénom' }, + { key: 'associated_profile_email', label: 'Email' }, + { + key: 'specialities_details', + label: 'Spécialités', + transform: (value) => value ? value.map(s => s.name).join(', ') : '' + }, + { + key: 'role_type', + label: 'Profil', + transform: (value) => value === 1 ? 'Administrateur' : 'Enseignant' + }, + { key: 'school_year', label: 'Année scolaire' }, + { key: 'updated_date_formatted', label: 'Date de mise à jour' }, + ]; + const filename = `enseignants_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`; + exportToCSV(teachers, exportColumns, filename); + }; + const handleRemoveTeacher = (id) => { logger.debug('[DELETE] Suppression teacher id:', id); return handleDelete(id) @@ -242,6 +288,7 @@ const TeachersSection = ({ }, }), }, + school_year: formData.school_year || selectedSchoolYear, specialities: formData.specialities || [], }; @@ -293,6 +340,7 @@ const TeachersSection = ({ handleEdit(id, { last_name: updatedData.last_name, first_name: updatedData.first_name, + school_year: updatedData.school_year || selectedSchoolYear, profile_role_data: profileRoleData, specialities: updatedData.specialities || [], }) @@ -391,6 +439,20 @@ const TeachersSection = ({ /> ); + case 'ANNÉE SCOLAIRE': + return ( + + ); case 'ACTIONS': return (
@@ -459,6 +521,8 @@ const TeachersSection = ({ } else { return Non définie; } + case 'ANNÉE SCOLAIRE': + return teacher.school_year; case 'MISE A JOUR': return teacher.updated_date_formatted; case 'ACTIONS': @@ -526,6 +590,7 @@ const TeachersSection = ({ { name: 'EMAIL', label: 'Email' }, { name: 'SPECIALITES', label: 'Spécialités' }, { name: 'ADMINISTRATEUR', label: 'Profil' }, + { name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' }, { name: 'MISE A JOUR', label: 'Mise à jour' }, ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []), ]; @@ -539,6 +604,16 @@ const TeachersSection = ({ description="Gérez les enseignants.es de votre école" button={profileRole !== 0} onClick={handleAddTeacher} + secondaryButton={ + + } />
{ + const [activeYearFilter, setActiveYearFilter] = useState(CURRENT_YEAR_FILTER); + + const currentSchoolYear = useMemo(() => getCurrentSchoolYear(), []); + const nextSchoolYear = useMemo(() => getNextSchoolYear(), []); + const historicalYears = useMemo(() => getHistoricalYears(5), []); + + /** + * Retourne l'année scolaire sélectionnée selon le filtre actif. + */ + const selectedSchoolYear = useMemo(() => { + if (activeYearFilter === CURRENT_YEAR_FILTER) return currentSchoolYear; + if (activeYearFilter === NEXT_YEAR_FILTER) return nextSchoolYear; + return historicalYears[0]; + }, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]); + + /** + * Filtre un tableau d'éléments par année scolaire. + * @param {Array} items - Les éléments à filtrer + * @param {string} yearField - Le nom du champ contenant l'année scolaire (défaut: 'school_year') + */ + const filterByYear = useMemo(() => (items, yearField = 'school_year') => { + if (!items) return []; + if (activeYearFilter === CURRENT_YEAR_FILTER) { + return items.filter((item) => item[yearField] === currentSchoolYear); + } else if (activeYearFilter === NEXT_YEAR_FILTER) { + return items.filter((item) => item[yearField] === nextSchoolYear); + } else if (activeYearFilter === HISTORICAL_FILTER) { + return items.filter((item) => historicalYears.includes(item[yearField])); + } + return items; + }, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]); + + /** + * Calcule les compteurs pour chaque filtre. + * @param {Array} items - Les éléments à compter + * @param {string} yearField - Le nom du champ contenant l'année scolaire + */ + const getYearCounts = useMemo(() => (items, yearField = 'school_year') => { + if (!items) return { currentYear: 0, nextYear: 0, historical: 0 }; + return { + currentYear: items.filter((item) => item[yearField] === currentSchoolYear).length, + nextYear: items.filter((item) => item[yearField] === nextSchoolYear).length, + historical: items.filter((item) => historicalYears.includes(item[yearField])).length, + }; + }, [currentSchoolYear, nextSchoolYear, historicalYears]); + + const value = { + activeYearFilter, + setActiveYearFilter, + currentSchoolYear, + nextSchoolYear, + historicalYears, + selectedSchoolYear, + filterByYear, + getYearCounts, + }; + + return ( + + {children} + + ); +}; + +export const useSchoolYearFilter = () => { + const context = useContext(SchoolYearFilterContext); + if (!context) { + throw new Error('useSchoolYearFilter must be used within a SchoolYearFilterProvider'); + } + return context; +}; + +export default SchoolYearFilterContext; diff --git a/Front-End/src/utils/Url.js b/Front-End/src/utils/Url.js index d325afa..78aa9cf 100644 --- a/Front-End/src/utils/Url.js +++ b/Front-End/src/utils/Url.js @@ -42,6 +42,7 @@ export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`; export const BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`; export const BE_SCHOOL_EVALUATIONS_URL = `${BASE_URL}/School/evaluations`; export const BE_SCHOOL_STUDENT_EVALUATIONS_URL = `${BASE_URL}/School/studentEvaluations`; +export const BE_SCHOOL_SCHOOL_YEARS_URL = `${BASE_URL}/School/schoolYears`; // ESTABLISHMENT export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`; diff --git a/Front-End/src/utils/exportCSV.js b/Front-End/src/utils/exportCSV.js new file mode 100644 index 0000000..25f93c2 --- /dev/null +++ b/Front-End/src/utils/exportCSV.js @@ -0,0 +1,93 @@ +/** + * Utilitaire d'export CSV + * Génère et télécharge un fichier CSV à partir de données + */ + +/** + * Échappe les valeurs CSV (guillemets, virgules, retours à la ligne) + * @param {*} value - La valeur à échapper + * @returns {string} - La valeur échappée + */ +const escapeCSVValue = (value) => { + if (value === null || value === undefined) { + return ''; + } + const stringValue = String(value); + // Si la valeur contient des guillemets, des virgules ou des retours à la ligne + if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes(';')) { + // Échapper les guillemets en les doublant et entourer de guillemets + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +}; + +/** + * Convertit un tableau d'objets en chaîne CSV + * @param {Array} data - Les données à convertir + * @param {Array<{key: string, label: string, transform?: Function}>} columns - Configuration des colonnes + * @param {string} separator - Le séparateur (par défaut ';' pour compatibilité Excel FR) + * @returns {string} - La chaîne CSV + */ +export const convertToCSV = (data, columns, separator = ';') => { + if (!data || data.length === 0) { + return ''; + } + + // En-têtes + const headers = columns.map((col) => escapeCSVValue(col.label)).join(separator); + + // Lignes de données + const rows = data.map((item) => { + return columns + .map((col) => { + let value = item[col.key]; + // Appliquer la transformation si elle existe + if (col.transform && typeof col.transform === 'function') { + value = col.transform(value, item); + } + return escapeCSVValue(value); + }) + .join(separator); + }); + + return [headers, ...rows].join('\n'); +}; + +/** + * Télécharge un fichier CSV + * @param {string} csvContent - Le contenu CSV + * @param {string} filename - Le nom du fichier (sans extension) + */ +export const downloadCSV = (csvContent, filename) => { + // Ajouter le BOM UTF-8 pour compatibilité Excel + const BOM = '\uFEFF'; + const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + + if (navigator.msSaveBlob) { + // IE 10+ + navigator.msSaveBlob(blob, `${filename}.csv`); + } else { + const url = URL.createObjectURL(blob); + link.href = url; + link.download = `${filename}.csv`; + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } +}; + +/** + * Exporte des données vers un fichier CSV et le télécharge + * @param {Array} data - Les données à exporter + * @param {Array<{key: string, label: string, transform?: Function}>} columns - Configuration des colonnes + * @param {string} filename - Le nom du fichier (sans extension) + */ +export const exportToCSV = (data, columns, filename) => { + const csvContent = convertToCSV(data, columns); + downloadCSV(csvContent, filename); +}; + +export default exportToCSV;