feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5]

This commit is contained in:
N3WT DE COMPET
2026-04-04 13:51:43 +02:00
parent 2579af9b8b
commit f091fa0432
18 changed files with 796 additions and 134 deletions

View File

@ -21,6 +21,7 @@ class Speciality(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
color_code = models.CharField(max_length=7, default='#FFFFFF') 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') establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='specialities')
def __str__(self): def __str__(self):
@ -31,6 +32,7 @@ class Teacher(models.Model):
first_name = models.CharField(max_length=100) first_name = models.CharField(max_length=100)
specialities = models.ManyToManyField(Speciality, blank=True) specialities = models.ManyToManyField(Speciality, blank=True)
profile_role = models.OneToOneField('Auth.ProfileRole', on_delete=models.CASCADE, related_name='teacher_profile', null=True, 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) updated_date = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
@ -48,6 +50,7 @@ class SchoolClass(models.Model):
number_of_students = models.PositiveIntegerField(null=True, blank=True) number_of_students = models.PositiveIntegerField(null=True, blank=True)
teaching_language = models.CharField(max_length=255, blank=True) teaching_language = models.CharField(max_length=255, blank=True)
school_year = models.CharField(max_length=9, 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) updated_date = models.DateTimeField(auto_now=True)
teachers = models.ManyToManyField(Teacher, blank=True) teachers = models.ManyToManyField(Teacher, blank=True)
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes') levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')

View File

@ -13,6 +13,7 @@ from .views import (
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView, EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
EvaluationListCreateView, EvaluationDetailView, EvaluationListCreateView, EvaluationDetailView,
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView, StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
SchoolYearsListView,
) )
urlpatterns = [ urlpatterns = [
@ -54,4 +55,7 @@ urlpatterns = [
re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"), 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/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"),
re_path(r'^studentEvaluations/(?P<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"), re_path(r'^studentEvaluations/(?P<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
# History / School Years
re_path(r'^schoolYears$', SchoolYearsListView.as_view(), name="school_years_list"),
] ]

View File

@ -38,12 +38,34 @@ from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from django.db.models import Q from django.db.models import Q
from collections import defaultdict from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency, StudentEvaluation from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
from Subscriptions.util import getCurrentSchoolYear from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears
import logging import logging
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
logger = logging.getLogger(__name__) 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(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityListCreateView(APIView): class SpecialityListCreateView(APIView):
@ -186,12 +208,33 @@ class SchoolClassListCreateView(APIView):
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) 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: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
school_classes_list = getAllObjects(SchoolClass) school_classes_list = getAllObjects(SchoolClass)
if school_classes_list: 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) classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
return JsonResponse(classes_serializer.data, safe=False) return JsonResponse(classes_serializer.data, safe=False)

View File

@ -130,6 +130,10 @@ class Student(models.Model):
# One-to-Many Relationship # One-to-Many Relationship
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students') 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): def __str__(self):
return self.last_name + "_" + self.first_name return self.last_name + "_" + self.first_name
@ -252,6 +256,7 @@ class RegistrationForm(models.Model):
# One-to-One Relationship # One-to-One Relationship
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True) student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE) 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) last_update = models.DateTimeField(auto_now=True)
school_year = models.CharField(max_length=9, default="", blank=True) school_year = models.CharField(max_length=9, default="", blank=True)
notes = models.CharField(max_length=200, blank=True) notes = models.CharField(max_length=200, blank=True)
@ -578,6 +583,8 @@ class StudentCompetency(models.Model):
default="", default="",
blank=True blank=True
) )
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
class Meta: class Meta:
unique_together = ('student', 'establishment_competency', 'period') unique_together = ('student', 'establishment_competency', 'period')

View File

@ -54,6 +54,12 @@ class StudentListView(APIView):
description="ID de l'établissement", description="ID de l'établissement",
type=openapi.TYPE_INTEGER, type=openapi.TYPE_INTEGER,
required=True 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): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel 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: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) 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: if status_filter:
students_qs = students_qs.filter(registrationform__status=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_qs = students_qs.distinct()
students_serializer = StudentByRFCreationSerializer(students_qs, many=True) students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
return JsonResponse(students_serializer.data, safe=False) return JsonResponse(students_serializer.data, safe=False)

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save } from 'lucide-react'; import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save, Download } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import Table from '@/components/Table'; import Table from '@/components/Table';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -19,14 +19,20 @@ import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluati
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext'; 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'; import dayjs from 'dayjs';
function getPeriodString(periodValue, frequency) { function getPeriodString(periodValue, frequency, schoolYear = null) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; const year = schoolYear || (() => {
const schoolYear = `${year}-${year + 1}`; const y = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
if (frequency === 1) return `T${periodValue}_${schoolYear}`; return `${y}-${y + 1}`;
if (frequency === 2) return `S${periodValue}_${schoolYear}`; })();
if (frequency === 3) return `A_${schoolYear}`; if (frequency === 1) return `T${periodValue}_${year}`;
if (frequency === 2) return `S${periodValue}_${year}`;
if (frequency === 3) return `A_${year}`;
return ''; return '';
} }
@ -132,17 +138,33 @@ export default function Page() {
const [editingEvalId, setEditingEvalId] = useState(null); const [editingEvalId, setEditingEvalId] = useState(null);
const [editScore, setEditScore] = useState(''); const [editScore, setEditScore] = useState('');
const [editAbsent, setEditAbsent] = useState(false); 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
); ), [selectedEstablishmentEvaluationFrequency]);
const currentPeriodValue = getCurrentPeriodValue( const currentPeriodValue = getCurrentPeriodValue(
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency
); );
useEffect(() => { useEffect(() => {
if (!selectedEstablishmentId) return; if (!selectedEstablishmentId) return;
fetchStudents(selectedEstablishmentId, null, 5) fetchStudents(selectedEstablishmentId, null, 5, selectedSchoolYear)
.then((data) => setStudents(data)) .then((data) => setStudents(data))
.catch((error) => logger.error('Error fetching students:', error)); .catch((error) => logger.error('Error fetching students:', error));
@ -157,7 +179,7 @@ export default function Page() {
setAbsencesMap(map); setAbsencesMap(map);
}) })
.catch((error) => logger.error('Error fetching absences:', error)); .catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]); }, [selectedEstablishmentId, selectedSchoolYear]);
// Fetch stats for all students - aggregate all periods // Fetch stats for all students - aggregate all periods
useEffect(() => { useEffect(() => {
@ -168,7 +190,7 @@ export default function Page() {
const tasks = students.flatMap((student) => const tasks = students.flatMap((student) =>
periodColumns.map(({ value: periodValue }) => { periodColumns.map(({ value: periodValue }) => {
const periodStr = getPeriodString(periodValue, frequency); const periodStr = getPeriodString(periodValue, frequency, selectedSchoolYear);
return fetchStudentCompetencies(student.id, periodStr) return fetchStudentCompetencies(student.id, periodStr)
.then((data) => ({ studentId: student.id, periodValue, data })) .then((data) => ({ studentId: student.id, periodValue, data }))
.catch(() => ({ studentId: student.id, periodValue, data: null })); .catch(() => ({ studentId: student.id, periodValue, data: null }));
@ -207,7 +229,7 @@ export default function Page() {
setStatsMap(map); setStatsMap(map);
setStatsLoading(false); setStatsLoading(false);
}); });
}, [students, selectedEstablishmentEvaluationFrequency]); }, [students, selectedEstablishmentEvaluationFrequency, selectedSchoolYear, periodColumns]);
const filteredStudents = students.filter( const filteredStudents = students.filter(
(student) => (student) =>
@ -249,6 +271,38 @@ export default function Page() {
setEditingEvalId(null); 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) => { const startEditingEval = (evalItem) => {
setEditingEvalId(evalItem.id); setEditingEvalId(evalItem.id);
setEditScore(evalItem.score ?? ''); setEditScore(evalItem.score ?? '');
@ -306,7 +360,8 @@ export default function Page() {
e.stopPropagation(); e.stopPropagation();
const periodStr = getPeriodString( const periodStr = getPeriodString(
currentPeriodValue, currentPeriodValue,
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency,
selectedSchoolYear
); );
router.push( router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}` `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
@ -434,18 +489,34 @@ export default function Page() {
title="Suivi pédagogique" title="Suivi pédagogique"
description="Suivez le parcours d'un élève" description="Suivez le parcours d'un élève"
/> />
<div className="relative flex-grow max-w-md"> <SchoolYearFilter
<Search activeFilter={activeYearFilter}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" onFilterChange={setActiveYearFilter}
size={20} showNextYear={true}
/> showHistorical={true}
<input />
type="text" <div className="flex justify-between items-center w-full">
placeholder="Rechercher un élève" <div className="relative flex-grow">
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md" <Search
value={searchTerm} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
onChange={(e) => setSearchTerm(e.target.value)} size={20}
/> />
<input
type="text"
placeholder="Rechercher un élève"
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors ml-4"
title="Exporter en CSV"
>
<Download className="w-4 h-4" />
Exporter
</button>
</div> </div>
<Table <Table

View File

@ -19,6 +19,7 @@ import {
Upload, Upload,
Eye, Eye,
XCircle, XCircle,
Download,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -55,6 +56,7 @@ import {
} from '@/utils/constants'; } from '@/utils/constants';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import { exportToCSV } from '@/utils/exportCSV';
export default function Page({ params: { locale } }) { export default function Page({ params: { locale } }) {
const t = useTranslations('subscriptions'); const t = useTranslations('subscriptions');
@ -149,6 +151,49 @@ export default function Page({ params: { locale } }) {
setIsFilesModalOpen(true); setIsFilesModalOpen(true);
}; };
// Export CSV
const handleExportCSV = () => {
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) => { const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err); logger.error('Error fetching data:', err);
}; };
@ -839,17 +884,27 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
</div> </div>
{profileRole !== 0 && ( <div className="flex items-center gap-2 ml-4">
<button <button
onClick={() => { onClick={handleExportCSV}
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`; className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
router.push(url); title="Exporter en CSV"
}}
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
> >
<Plus className="w-5 h-5" /> <Download className="w-4 h-4" />
Exporter
</button> </button>
)} {profileRole !== 0 && (
<button
onClick={() => {
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
router.push(url);
}}
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200"
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
</div> </div>
<div className="w-full"> <div className="w-full">

View File

@ -11,6 +11,7 @@ import {
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
BE_SCHOOL_EVALUATIONS_URL, BE_SCHOOL_EVALUATIONS_URL,
BE_SCHOOL_STUDENT_EVALUATIONS_URL, BE_SCHOOL_STUDENT_EVALUATIONS_URL,
BE_SCHOOL_SCHOOL_YEARS_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { fetchWithAuth } from '@/utils/fetchWithAuth'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
@ -46,10 +47,15 @@ export const fetchTeachers = (establishment) => {
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`); return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
}; };
export const fetchClasses = (establishment) => { export const fetchClasses = (establishment, options = {}) => {
return fetchWithAuth( let url = `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`;
`${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) => { export const fetchClasse = (id) => {

View File

@ -124,7 +124,7 @@ export const searchStudents = (establishmentId, query) => {
return fetchWithAuth(url); return fetchWithAuth(url);
}; };
export const fetchStudents = (establishment, id = null, status = null) => { export const fetchStudents = (establishment, id = null, status = null, schoolYear = null) => {
let url; let url;
if (id) { if (id) {
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`; url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
@ -133,6 +133,9 @@ export const fetchStudents = (establishment, id = null, status = null) => {
if (status) { if (status) {
url += `&status=${status}`; url += `&status=${status}`;
} }
if (schoolYear) {
url += `&school_year=${encodeURIComponent(schoolYear)}`;
}
} }
return fetchWithAuth(url); return fetchWithAuth(url);
}; };

View File

@ -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 (
<div className="flex flex-wrap gap-2 mb-4">
<Tab
text={`${currentSchoolYear}${counts.currentYear !== undefined ? ` (${counts.currentYear})` : ''}`}
active={activeFilter === CURRENT_YEAR_FILTER}
onClick={() => onFilterChange(CURRENT_YEAR_FILTER)}
/>
{showNextYear && (
<Tab
text={`${nextSchoolYear}${counts.nextYear !== undefined ? ` (${counts.nextYear})` : ''}`}
active={activeFilter === NEXT_YEAR_FILTER}
onClick={() => onFilterChange(NEXT_YEAR_FILTER)}
/>
)}
{showHistorical && (
<Tab
text={`Archives${counts.historical !== undefined ? ` (${counts.historical})` : ''}`}
active={activeFilter === HISTORICAL_FILTER}
onClick={() => onFilterChange(HISTORICAL_FILTER)}
/>
)}
</div>
);
};
export default SchoolYearFilter;

View File

@ -9,6 +9,7 @@ const SectionHeader = ({
button = false, button = false,
buttonOpeningModal = false, buttonOpeningModal = false,
onClick = null, onClick = null,
secondaryButton = null, // Bouton secondaire (ex: export)
}) => { }) => {
return ( return (
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@ -29,18 +30,21 @@ const SectionHeader = ({
<p className="text-sm text-gray-500 italic">{description}</p> <p className="text-sm text-gray-500 italic">{description}</p>
</div> </div>
</div> </div>
{button && onClick && ( <div className="flex items-center gap-2">
<button {secondaryButton}
onClick={onClick} {button && onClick && (
className={ <button
buttonOpeningModal onClick={onClick}
? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300' className={
: 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2' buttonOpeningModal
} ? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300'
> : 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2'
<Plus className="w-6 h-6" /> }
</button> >
)} <Plus className="w-6 h-6" />
</button>
)}
</div>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react'; import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand, Download } from 'lucide-react';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputText from '@/components/Form/InputText'; import InputText from '@/components/Form/InputText';
@ -17,6 +17,8 @@ import { usePlanning } from '@/context/PlanningContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
import { exportToCSV } from '@/utils/exportCSV';
const ItemTypes = { const ItemTypes = {
TEACHER: 'teacher', TEACHER: 'teacher',
@ -115,6 +117,7 @@ const TeachersDropZone = ({
const ClassesSection = ({ const ClassesSection = ({
classes, classes,
allClasses,
setClasses, setClasses,
teachers, teachers,
handleCreate, handleCreate,
@ -132,13 +135,15 @@ const ClassesSection = ({
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10; const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
// Les classes arrivent déjà filtrées depuis le parent
useEffect(() => { setCurrentPage(1); }, [classes]); useEffect(() => { setCurrentPage(1); }, [classes]);
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]); useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE); const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses(); const { getNiveauxLabels, getNiveauLabel, allNiveaux } = useClasses();
const router = useRouter(); const router = useRouter();
// Fonction pour générer les années scolaires // 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 handleChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -559,6 +588,16 @@ const ClassesSection = ({
description="Gérez les classes de votre école" description="Gérez les classes de votre école"
button={profileRole !== 0} button={profileRole !== 0}
onClick={handleAddClass} onClick={handleAddClass}
secondaryButton={
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
title="Exporter en CSV"
>
<Download className="w-4 h-4" />
Exporter
</button>
}
/> />
<Table <Table
data={newClass ? [newClass, ...pagedClasses] : pagedClasses} data={newClass ? [newClass, ...pagedClasses] : pagedClasses}

View File

@ -1,18 +1,22 @@
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react'; import { Trash2, Edit3, Check, X, BookOpen, Download } from 'lucide-react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon'; import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
import SelectChoice from '@/components/Form/SelectChoice';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import { exportToCSV } from '@/utils/exportCSV';
const SpecialitiesSection = ({ const SpecialitiesSection = ({
specialities, specialities,
allSpecialities,
setSpecialities, setSpecialities,
handleCreate, handleCreate,
handleEdit, handleEdit,
@ -36,6 +40,21 @@ const SpecialitiesSection = ({
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { selectedSchoolYear } = useSchoolYearFilter();
// Fonction pour générer les années scolaires
const getSchoolYearChoices = () => {
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 // Récupération des messages d'erreur
const getError = (field) => { const getError = (field) => {
@ -43,7 +62,20 @@ const SpecialitiesSection = ({
}; };
const handleAddSpeciality = () => { 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) => { const handleRemoveSpeciality = (id) => {
@ -150,6 +182,20 @@ const SpecialitiesSection = ({
errorMsg={getError('name')} errorMsg={getError('name')}
/> />
); );
case 'ANNÉE SCOLAIRE':
return (
<SelectChoice
type="select"
name="school_year"
placeHolder="Sélectionnez une année scolaire"
choices={getSchoolYearChoices()}
callback={handleChange}
selected={currentData.school_year || ''}
errorMsg={getError('school_year')}
IconItem={null}
disabled={false}
/>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -184,6 +230,8 @@ const SpecialitiesSection = ({
switch (column) { switch (column) {
case 'LIBELLE': case 'LIBELLE':
return <SpecialityItem key={speciality.id} speciality={speciality} />; return <SpecialityItem key={speciality.id} speciality={speciality} />;
case 'ANNÉE SCOLAIRE':
return speciality.school_year;
case 'MISE A JOUR': case 'MISE A JOUR':
return speciality.updated_date_formatted; return speciality.updated_date_formatted;
case 'ACTIONS': case 'ACTIONS':
@ -244,6 +292,7 @@ const SpecialitiesSection = ({
const columns = [ const columns = [
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []), ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
]; ];
@ -257,6 +306,16 @@ const SpecialitiesSection = ({
description="Gérez les spécialités de votre école" description="Gérez les spécialités de votre école"
button={profileRole !== 0} button={profileRole !== 0}
onClick={handleAddSpeciality} onClick={handleAddSpeciality}
secondaryButton={
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
title="Exporter en CSV"
>
<Download className="w-4 h-4" />
Exporter
</button>
}
/> />
<Table <Table
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities} data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}

View File

@ -1,14 +1,126 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection'; import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection';
import TeachersSection from '@/components/Structure/Configuration/TeachersSection'; import TeachersSection from '@/components/Structure/Configuration/TeachersSection';
import ClassesSection from '@/components/Structure/Configuration/ClassesSection'; import ClassesSection from '@/components/Structure/Configuration/ClassesSection';
import { ClassesProvider } from '@/context/ClassesContext'; import { ClassesProvider } from '@/context/ClassesContext';
import { SchoolYearFilterProvider, useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
import SchoolYearFilter from '@/components/SchoolYearFilter';
import { import {
BE_SCHOOL_SPECIALITIES_URL, BE_SCHOOL_SPECIALITIES_URL,
BE_SCHOOL_TEACHERS_URL, BE_SCHOOL_TEACHERS_URL,
BE_SCHOOL_SCHOOLCLASSES_URL, BE_SCHOOL_SCHOOLCLASSES_URL,
} from '@/utils/Url'; } from '@/utils/Url';
const StructureManagementContent = ({
specialities,
setSpecialities,
teachers,
setTeachers,
classes,
setClasses,
profiles,
handleCreate,
handleEdit,
handleDelete,
}) => {
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 (
<>
<div className="mb-6">
<SchoolYearFilter
activeFilter={activeYearFilter}
onFilterChange={setActiveYearFilter}
showNextYear={true}
showHistorical={true}
/>
</div>
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
<div className="mt-8 flex flex-col xl:flex-row gap-8">
<div className="w-full xl:w-2/5">
<SpecialitiesSection
specialities={filteredSpecialities}
allSpecialities={specialities}
setSpecialities={setSpecialities}
handleCreate={(newData) =>
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)
}
/>
</div>
<div className="w-full xl:flex-1">
<TeachersSection
teachers={filteredTeachers}
allTeachers={teachers}
setTeachers={setTeachers}
specialities={filteredSpecialities}
profiles={profiles}
handleCreate={(newData) =>
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)
}
/>
</div>
</div>
<div className="w-full mt-8 xl:mt-12">
<ClassesSection
classes={filteredClasses}
allClasses={classes}
setClasses={setClasses}
teachers={filteredTeachers}
handleCreate={(newData) =>
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)
}
/>
</div>
</>
);
};
const StructureManagement = ({ const StructureManagement = ({
specialities, specialities,
setSpecialities, setSpecialities,
@ -23,82 +135,22 @@ const StructureManagement = ({
}) => { }) => {
return ( return (
<div className="w-full"> <div className="w-full">
<ClassesProvider> <SchoolYearFilterProvider>
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */} <ClassesProvider>
<div className="mt-8 flex flex-col xl:flex-row gap-8"> <StructureManagementContent
<div className="w-full xl:w-2/5"> specialities={specialities}
<SpecialitiesSection setSpecialities={setSpecialities}
specialities={specialities} teachers={teachers}
setSpecialities={setSpecialities} setTeachers={setTeachers}
handleCreate={(newData) =>
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)
}
/>
</div>
<div className="w-full xl:flex-1">
<TeachersSection
teachers={teachers}
setTeachers={setTeachers}
specialities={specialities}
profiles={profiles}
handleCreate={(newData) =>
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)
}
/>
</div>
</div>
<div className="w-full mt-8 xl:mt-12">
<ClassesSection
classes={classes} classes={classes}
setClasses={setClasses} setClasses={setClasses}
teachers={teachers} profiles={profiles}
handleCreate={(newData) => handleCreate={handleCreate}
handleCreate( handleEdit={handleEdit}
`${BE_SCHOOL_SCHOOLCLASSES_URL}`, handleDelete={handleDelete}
newData,
setClasses
)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
id,
updatedData,
setClasses
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)
}
/> />
</div> </ClassesProvider>
</ClassesProvider> </SchoolYearFilterProvider>
</div> </div>
); );
}; };

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'; 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 Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import { DndProvider, useDrop } from 'react-dnd'; import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/Form/InputText'; import InputText from '@/components/Form/InputText';
@ -10,8 +11,10 @@ import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'
import TeacherItem from './TeacherItem'; import TeacherItem from './TeacherItem';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import { exportToCSV } from '@/utils/exportCSV';
const ItemTypes = { const ItemTypes = {
SPECIALITY: 'speciality', SPECIALITY: 'speciality',
@ -120,6 +123,7 @@ const SpecialitiesDropZone = ({
const TeachersSection = ({ const TeachersSection = ({
teachers, teachers,
allTeachers,
setTeachers, setTeachers,
specialities, specialities,
profiles, profiles,
@ -145,6 +149,22 @@ const TeachersSection = ({
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); 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 --- // --- UTILS ---
@ -197,6 +217,7 @@ const TeachersSection = ({
associated_profile_email: '', associated_profile_email: '',
specialities: [], specialities: [],
role_type: 0, role_type: 0,
school_year: selectedSchoolYear,
}); });
setFormData({ setFormData({
last_name: '', last_name: '',
@ -204,9 +225,34 @@ const TeachersSection = ({
associated_profile_email: '', associated_profile_email: '',
specialities: [], specialities: [],
role_type: 0, 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) => { const handleRemoveTeacher = (id) => {
logger.debug('[DELETE] Suppression teacher id:', id); logger.debug('[DELETE] Suppression teacher id:', id);
return handleDelete(id) return handleDelete(id)
@ -242,6 +288,7 @@ const TeachersSection = ({
}, },
}), }),
}, },
school_year: formData.school_year || selectedSchoolYear,
specialities: formData.specialities || [], specialities: formData.specialities || [],
}; };
@ -293,6 +340,7 @@ const TeachersSection = ({
handleEdit(id, { handleEdit(id, {
last_name: updatedData.last_name, last_name: updatedData.last_name,
first_name: updatedData.first_name, first_name: updatedData.first_name,
school_year: updatedData.school_year || selectedSchoolYear,
profile_role_data: profileRoleData, profile_role_data: profileRoleData,
specialities: updatedData.specialities || [], specialities: updatedData.specialities || [],
}) })
@ -391,6 +439,20 @@ const TeachersSection = ({
/> />
</div> </div>
); );
case 'ANNÉE SCOLAIRE':
return (
<SelectChoice
type="select"
name="school_year"
placeHolder="Sélectionnez une année scolaire"
choices={getSchoolYearChoices()}
callback={handleChange}
selected={currentData.school_year || ''}
errorMsg={getError('school_year')}
IconItem={null}
disabled={false}
/>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -459,6 +521,8 @@ const TeachersSection = ({
} else { } else {
return <i>Non définie</i>; return <i>Non définie</i>;
} }
case 'ANNÉE SCOLAIRE':
return teacher.school_year;
case 'MISE A JOUR': case 'MISE A JOUR':
return teacher.updated_date_formatted; return teacher.updated_date_formatted;
case 'ACTIONS': case 'ACTIONS':
@ -526,6 +590,7 @@ const TeachersSection = ({
{ name: 'EMAIL', label: 'Email' }, { name: 'EMAIL', label: 'Email' },
{ name: 'SPECIALITES', label: 'Spécialités' }, { name: 'SPECIALITES', label: 'Spécialités' },
{ name: 'ADMINISTRATEUR', label: 'Profil' }, { name: 'ADMINISTRATEUR', label: 'Profil' },
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
{ name: 'MISE A JOUR', label: 'Mise à jour' }, { name: 'MISE A JOUR', label: 'Mise à jour' },
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []), ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
]; ];
@ -539,6 +604,16 @@ const TeachersSection = ({
description="Gérez les enseignants.es de votre école" description="Gérez les enseignants.es de votre école"
button={profileRole !== 0} button={profileRole !== 0}
onClick={handleAddTeacher} onClick={handleAddTeacher}
secondaryButton={
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
title="Exporter en CSV"
>
<Download className="w-4 h-4" />
Exporter
</button>
}
/> />
<Table <Table
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers} data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}

View File

@ -0,0 +1,85 @@
'use client';
import React, { createContext, useContext, useState, useMemo } from 'react';
import { getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears } from '@/utils/Date';
import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants';
const SchoolYearFilterContext = createContext(null);
/**
* Provider pour le filtre d'année scolaire partagé entre plusieurs composants.
* Permet d'avoir un seul filtre qui s'applique sur plusieurs tableaux.
*/
export const SchoolYearFilterProvider = ({ children }) => {
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 (
<SchoolYearFilterContext.Provider value={value}>
{children}
</SchoolYearFilterContext.Provider>
);
};
export const useSchoolYearFilter = () => {
const context = useContext(SchoolYearFilterContext);
if (!context) {
throw new Error('useSchoolYearFilter must be used within a SchoolYearFilterProvider');
}
return context;
};
export default SchoolYearFilterContext;

View File

@ -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_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`;
export const BE_SCHOOL_EVALUATIONS_URL = `${BASE_URL}/School/evaluations`; 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_STUDENT_EVALUATIONS_URL = `${BASE_URL}/School/studentEvaluations`;
export const BE_SCHOOL_SCHOOL_YEARS_URL = `${BASE_URL}/School/schoolYears`;
// ESTABLISHMENT // ESTABLISHMENT
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`; export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;

View File

@ -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<Object>} 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<Object>} 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;