mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5]
This commit is contained in:
@ -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')
|
||||
|
||||
@ -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<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
|
||||
|
||||
# History / School Years
|
||||
re_path(r'^schoolYears$', SchoolYearsListView.as_view(), name="school_years_list"),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
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 Table from '@/components/Table';
|
||||
import logger from '@/utils/logger';
|
||||
@ -19,14 +19,20 @@ import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluati
|
||||
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 '';
|
||||
}
|
||||
|
||||
@ -132,17 +138,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));
|
||||
|
||||
@ -157,7 +179,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(() => {
|
||||
@ -168,7 +190,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 }));
|
||||
@ -207,7 +229,7 @@ export default function Page() {
|
||||
setStatsMap(map);
|
||||
setStatsLoading(false);
|
||||
});
|
||||
}, [students, selectedEstablishmentEvaluationFrequency]);
|
||||
}, [students, selectedEstablishmentEvaluationFrequency, selectedSchoolYear, periodColumns]);
|
||||
|
||||
const filteredStudents = students.filter(
|
||||
(student) =>
|
||||
@ -249,6 +271,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 ?? '');
|
||||
@ -306,7 +360,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}`
|
||||
@ -434,18 +489,34 @@ export default function Page() {
|
||||
title="Suivi pédagogique"
|
||||
description="Suivez le parcours d'un élève"
|
||||
/>
|
||||
<div className="relative flex-grow max-w-md">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
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)}
|
||||
/>
|
||||
<SchoolYearFilter
|
||||
activeFilter={activeYearFilter}
|
||||
onFilterChange={setActiveYearFilter}
|
||||
showNextYear={true}
|
||||
showHistorical={true}
|
||||
/>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
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>
|
||||
|
||||
<Table
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -55,6 +56,7 @@ import {
|
||||
} from '@/utils/constants';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
export default function Page({ params: { locale } }) {
|
||||
const t = useTranslations('subscriptions');
|
||||
@ -149,6 +151,49 @@ export default function Page({ params: { locale } }) {
|
||||
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) => {
|
||||
logger.error('Error fetching data:', err);
|
||||
};
|
||||
@ -839,17 +884,27 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{profileRole !== 0 && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<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 ml-4"
|
||||
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"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</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 className="w-full">
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
52
Front-End/src/components/SchoolYearFilter.js
Normal file
52
Front-End/src/components/SchoolYearFilter.js
Normal 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;
|
||||
@ -9,6 +9,7 @@ const SectionHeader = ({
|
||||
button = false,
|
||||
buttonOpeningModal = false,
|
||||
onClick = null,
|
||||
secondaryButton = null, // Bouton secondaire (ex: export)
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{button && onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
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>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{secondaryButton}
|
||||
{button && onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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={
|
||||
<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
|
||||
data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
|
||||
|
||||
@ -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 Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||
import logger from '@/utils/logger';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
const SpecialitiesSection = ({
|
||||
specialities,
|
||||
allSpecialities,
|
||||
setSpecialities,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
@ -36,6 +40,21 @@ const SpecialitiesSection = ({
|
||||
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
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
|
||||
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 (
|
||||
<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':
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
@ -184,6 +230,8 @@ const SpecialitiesSection = ({
|
||||
switch (column) {
|
||||
case 'LIBELLE':
|
||||
return <SpecialityItem key={speciality.id} speciality={speciality} />;
|
||||
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={
|
||||
<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
|
||||
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
|
||||
|
||||
@ -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 TeachersSection from '@/components/Structure/Configuration/TeachersSection';
|
||||
import ClassesSection from '@/components/Structure/Configuration/ClassesSection';
|
||||
import { ClassesProvider } from '@/context/ClassesContext';
|
||||
import { SchoolYearFilterProvider, useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||
import SchoolYearFilter from '@/components/SchoolYearFilter';
|
||||
import {
|
||||
BE_SCHOOL_SPECIALITIES_URL,
|
||||
BE_SCHOOL_TEACHERS_URL,
|
||||
BE_SCHOOL_SCHOOLCLASSES_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 = ({
|
||||
specialities,
|
||||
setSpecialities,
|
||||
@ -23,82 +135,22 @@ const StructureManagement = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ClassesProvider>
|
||||
{/* 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={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={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
|
||||
<SchoolYearFilterProvider>
|
||||
<ClassesProvider>
|
||||
<StructureManagementContent
|
||||
specialities={specialities}
|
||||
setSpecialities={setSpecialities}
|
||||
teachers={teachers}
|
||||
setTeachers={setTeachers}
|
||||
classes={classes}
|
||||
setClasses={setClasses}
|
||||
teachers={teachers}
|
||||
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)
|
||||
}
|
||||
profiles={profiles}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</ClassesProvider>
|
||||
</ClassesProvider>
|
||||
</SchoolYearFilterProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 = ({
|
||||
/>
|
||||
</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':
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
@ -459,6 +521,8 @@ const TeachersSection = ({
|
||||
} else {
|
||||
return <i>Non définie</i>;
|
||||
}
|
||||
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={
|
||||
<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
|
||||
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
|
||||
|
||||
85
Front-End/src/context/SchoolYearFilterContext.js
Normal file
85
Front-End/src/context/SchoolYearFilterContext.js
Normal 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;
|
||||
@ -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`;
|
||||
|
||||
93
Front-End/src/utils/exportCSV.js
Normal file
93
Front-End/src/utils/exportCSV.js
Normal 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;
|
||||
Reference in New Issue
Block a user