12 Commits

Author SHA1 Message Date
79e14a23fe Merge pull request 'feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5]' (!76) from N3WTS-5-Historique-ACA into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/76
2026-04-04 11:55:03 +00:00
269866fb1c Merge remote-tracking branch 'origin/develop' into N3WTS-5-Historique-ACA 2026-04-04 13:54:37 +02:00
f091fa0432 feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5] 2026-04-04 13:51:43 +02:00
a3291262d8 feat: Securisation du téléchargement de fichier 2026-04-04 13:44:57 +02:00
5f6c015d02 Merge branch 'worktree-design-system' into develop 2026-04-04 12:02:32 +02:00
09b1541dc8 feat: Ajout de la commande npm permettant de creer un etablissement 2026-04-04 11:57:59 +02:00
cb76a23d02 docs(design-system): add design system documentation and AI agent instructions
- Add docs/design-system.md with color tokens, typography, spacing, icons, responsive/PWA rules and component reuse guidelines
- Add CLAUDE.md with permanent instructions for Claude Code
- Add .github/instructions/design-system.instruction.md for GitHub Copilot
- Update .github/copilot-instructions.md to reference the design system
- Update Front-End/tailwind.config.js with color tokens (primary, secondary, tertiary, neutral) and font families (Manrope/Inter)
- Update Front-End/src/app/layout.js to load Manrope and Inter via next/font/google
2026-04-04 11:56:19 +02:00
2579af9b8b fix: coorection démarrage 2026-04-04 10:49:35 +02:00
3a132ae0bd Merge pull request 'N3WTS-6-Amelioration_Suivi_Eleve-ACA' (!75) from N3WTS-6-Amelioration_Suivi_Eleve-ACA into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/75
2026-04-04 06:36:09 +00:00
905fa5dbfb feat: Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6] 2026-04-03 22:10:32 +02:00
edb9ace6ae Merge remote-tracking branch 'origin/develop' into N3WTS-6-Amelioration_Suivi_Eleve-ACA 2026-04-03 17:35:29 +02:00
6fb3c5cdb4 feat: lister uniquement les élèves inscrits dans une classe [N3WTS-6] 2026-03-14 13:11:30 +01:00
55 changed files with 4781 additions and 620 deletions

View File

@ -52,8 +52,28 @@ Pour le front-end, les exigences de qualité sont les suivantes :
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
- Référence : [documentation guidelines](./instructions/documentation.instruction.md)
## Design System
Le projet utilise un design system défini. Toujours s'y conformer lors de toute modification de l'interface.
- Référence complète : [design system](../docs/design-system.md)
- Règles Copilot : [design system instructions](./instructions/design-system.instruction.md)
### Résumé des tokens obligatoires
| Token Tailwind | Hex | Usage |
|----------------|-----------|-------------------------------|
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
| `secondary` | `#064E3B` | Hover, accents sombres |
| `tertiary` | `#10B981` | Badges, icônes |
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
- Polices : `font-headline` (Manrope) pour les titres, `font-body`/`font-label` (Inter) pour le reste
- **Ne jamais** utiliser `emerald-*` pour les éléments interactifs
## Références
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
- **Tests** : [run tests](./instructions/run-tests.instruction.md)
- **Design System** : [design system instructions](./instructions/design-system.instruction.md)

View File

@ -0,0 +1,116 @@
---
applyTo: "Front-End/src/**"
---
# Design System — Règles Copilot
Référence complète : [`docs/design-system.md`](../../docs/design-system.md)
## Couleurs — tokens Tailwind obligatoires
Utiliser **toujours** ces tokens pour les éléments interactifs :
| Token | Hex | Remplace |
|-------------|-----------|-----------------------------------|
| `primary` | `#059669` | `emerald-600`, `emerald-500` |
| `secondary` | `#064E3B` | `emerald-700`, `emerald-800` |
| `tertiary` | `#10B981` | `emerald-400`, `emerald-500` |
| `neutral` | `#F8FAFC` | Fonds neutres |
**Ne jamais écrire** `bg-emerald-*`, `text-emerald-*`, `border-emerald-*` pour des éléments interactifs.
### Patterns corrects
```jsx
// Bouton
<button className="bg-primary hover:bg-secondary text-white px-4 py-2 rounded">
// Texte actif
<span className="text-primary">
// Badge
<span className="bg-tertiary/10 text-tertiary text-xs px-2 py-0.5 rounded">
// Fond de page
<div className="bg-neutral">
```
## Typographie
```jsx
// Titre de section
<h1 className="font-headline text-2xl font-bold">
// Sous-titre
<h2 className="font-headline text-xl font-semibold">
// Label de formulaire
<label className="font-label text-sm font-medium text-gray-700">
```
> `font-body` est le défaut sur `<body>` — inutile de l'ajouter sur les `<p>`.
## Arrondi
- Par défaut : `rounded` (4px)
- Cards / modales : `rounded-md` (6px)
- Grandes surfaces : `rounded-lg` (8px)
- **Éviter** `rounded-xl` sauf avatars ou indicateurs circulaires
## Espacement
- Grille 4px/8px : `p-1`=4px, `p-2`=8px, `p-3`=12px, `p-4`=16px
- **Pas** de valeurs arbitraires `p-[13px]`
## Mode
Interface **light uniquement** — ne pas ajouter `dark:` prefixes.
---
## Icônes
Utiliser **uniquement** `lucide-react`. Jamais d'autres bibliothèques d'icônes.
```jsx
import { Home, Plus, ChevronRight } from 'lucide-react';
<Home size={20} className="text-primary" />
<button className="flex items-center gap-2"><Plus size={16} />Ajouter</button>
```
- Taille par défaut : `size={20}` inline, `size={24}` boutons standalone
- Couleur via `className="text-*"` uniquement — jamais le prop `color`
- Icône seule : ajouter `aria-label` pour l'accessibilité
---
## Responsive & PWA
**Mobile-first** : les styles de base ciblent le mobile, on étend avec `sm:` / `md:` / `lg:`.
```jsx
// Layout
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
// Grille
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
// Bouton full-width mobile
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
```
- Touch targets ≥ 44px : `min-h-[44px]` sur tous les éléments interactifs
- Pas d'interactions uniquement au `:hover` — prévoir une alternative tactile
- Tableaux sur mobile : utiliser la classe utilitaire `responsive-table` (définie dans `tailwind.css`)
---
## Réutilisation des composants
**Toujours chercher un composant existant dans `Front-End/src/components/` avant d'en créer un.**
Composants clés disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
- Étendre via des props (`variant`, `size`, `className`) plutôt que de dupliquer
- Appliquer les tokens du design system dans tout composant modifié ou créé

View File

@ -1,5 +1,5 @@
La documentation doit être en français et claire pour les utilisateurs francophones.
Toutes la documentation doit être dans le dossier docs/
Toutes la documentation doit être dans le dossier docs/ à la racine.
Seul les fichiers README.md, CHANGELOG.md doivent être à la racine.
La documentation doit être conscise et pertinente, sans répétitions inutiles entre les documents.
Tout ce qui concerne la gestion de projet, roadmap ne doit pas apparaître dans la documentation.

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
hardcoded-strings-report.md
backend.env
*.log
.claude/worktrees/*

1
.husky/commit-msg Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
cd $(dirname "$0")/../Front-End/ && npm run lint-light

1
.husky/prepare-commit-msg Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
#node scripts/prepare-commit-msg.js "$1" "$2"

View File

@ -3,6 +3,7 @@ from django.urls import path, re_path
from .views import (
DomainListCreateView, DomainDetailView,
CategoryListCreateView, CategoryDetailView,
ServeFileView,
)
urlpatterns = [
@ -11,4 +12,6 @@ urlpatterns = [
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
]

View File

@ -1,3 +1,8 @@
import os
import mimetypes
from django.conf import settings
from django.http import FileResponse
from django.http.response import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator
@ -117,3 +122,55 @@ class CategoryDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
class ServeFileView(APIView):
"""Sert les fichiers media de manière sécurisée avec authentification JWT."""
permission_classes = [IsAuthenticated]
def get(self, request):
file_path = request.query_params.get('path', '')
if not file_path:
return JsonResponse(
{'error': 'Le paramètre "path" est requis'},
status=status.HTTP_400_BAD_REQUEST,
)
# Nettoyer le préfixe /data/ si présent
if file_path.startswith('/data/'):
file_path = file_path[len('/data/'):]
elif file_path.startswith('data/'):
file_path = file_path[len('data/'):]
# Construire le chemin absolu et le résoudre pour éliminer les traversals
absolute_path = os.path.realpath(
os.path.join(settings.MEDIA_ROOT, file_path)
)
# Protection contre le path traversal
media_root = os.path.realpath(settings.MEDIA_ROOT)
if not absolute_path.startswith(media_root + os.sep) and absolute_path != media_root:
return JsonResponse(
{'error': 'Accès non autorisé'},
status=status.HTTP_403_FORBIDDEN,
)
if not os.path.isfile(absolute_path):
return JsonResponse(
{'error': 'Fichier introuvable'},
status=status.HTTP_404_NOT_FOUND,
)
content_type, _ = mimetypes.guess_type(absolute_path)
if content_type is None:
content_type = 'application/octet-stream'
response = FileResponse(
open(absolute_path, 'rb'),
content_type=content_type,
)
response['Content-Disposition'] = (
f'inline; filename="{os.path.basename(absolute_path)}"'
)
return response

View File

@ -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')
@ -156,3 +159,26 @@ class EstablishmentCompetency(models.Model):
if self.competency:
return f"{self.establishment.name} - {self.competency.name}"
return f"{self.establishment.name} - {self.custom_name} (custom)"
class Evaluation(models.Model):
"""
Définition d'une évaluation (contrôle, examen, etc.) associée à une matière et une classe.
"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
speciality = models.ForeignKey(Speciality, on_delete=models.CASCADE, related_name='evaluations')
school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE, related_name='evaluations')
period = models.CharField(max_length=20, help_text="Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026")
date = models.DateField(null=True, blank=True)
max_score = models.DecimalField(max_digits=5, decimal_places=2, default=20)
coefficient = models.DecimalField(max_digits=3, decimal_places=2, default=1)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='evaluations')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-date', '-created_at']
def __str__(self):
return f"{self.name} - {self.speciality.name} ({self.school_class.atmosphere_name})"

View File

@ -10,10 +10,11 @@ from .models import (
PaymentPlan,
PaymentMode,
EstablishmentCompetency,
Competency
Competency,
Evaluation
)
from Auth.models import Profile, ProfileRole
from Subscriptions.models import Student
from Subscriptions.models import Student, StudentEvaluation
from Establishment.models import Establishment
from Auth.serializers import ProfileRoleSerializer
from N3wtSchool import settings
@ -182,12 +183,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
teachers_details = serializers.SerializerMethodField()
students = StudentDetailSerializer(many=True, read_only=True)
students = serializers.SerializerMethodField()
class Meta:
model = SchoolClass
fields = '__all__'
def get_students(self, obj):
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
validated_students = obj.students.filter(registrationform__status=5)
return StudentDetailSerializer(validated_students, many=True).data
def create(self, validated_data):
teachers_data = validated_data.pop('teachers', [])
levels_data = validated_data.pop('levels', [])
@ -300,3 +306,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
class Meta:
model = PaymentMode
fields = '__all__'
class EvaluationSerializer(serializers.ModelSerializer):
speciality_name = serializers.CharField(source='speciality.name', read_only=True)
speciality_color = serializers.CharField(source='speciality.color_code', read_only=True)
school_class_name = serializers.CharField(source='school_class.atmosphere_name', read_only=True)
class Meta:
model = Evaluation
fields = '__all__'
class StudentEvaluationSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField()
student_first_name = serializers.CharField(source='student.first_name', read_only=True)
student_last_name = serializers.CharField(source='student.last_name', read_only=True)
evaluation_name = serializers.CharField(source='evaluation.name', read_only=True)
max_score = serializers.DecimalField(source='evaluation.max_score', read_only=True, max_digits=5, decimal_places=2)
speciality_name = serializers.CharField(source='evaluation.speciality.name', read_only=True)
speciality_color = serializers.CharField(source='evaluation.speciality.color', read_only=True)
period = serializers.CharField(source='evaluation.period', read_only=True)
class Meta:
model = StudentEvaluation
fields = '__all__'
def get_student_name(self, obj):
return f"{obj.student.last_name} {obj.student.first_name}"

View File

@ -11,6 +11,9 @@ from .views import (
PaymentModeListCreateView, PaymentModeDetailView,
CompetencyListCreateView, CompetencyDetailView,
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
EvaluationListCreateView, EvaluationDetailView,
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
SchoolYearsListView,
)
urlpatterns = [
@ -43,4 +46,16 @@ urlpatterns = [
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
# Evaluations
re_path(r'^evaluations$', EvaluationListCreateView.as_view(), name="evaluation_list_create"),
re_path(r'^evaluations/(?P<id>[0-9]+)$', EvaluationDetailView.as_view(), name="evaluation_detail"),
# Student Evaluations
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"),
]

View File

@ -16,7 +16,8 @@ from .models import (
PaymentPlan,
PaymentMode,
EstablishmentCompetency,
Competency
Competency,
Evaluation
)
from .serializers import (
TeacherSerializer,
@ -28,19 +29,43 @@ from .serializers import (
PaymentPlanSerializer,
PaymentModeSerializer,
EstablishmentCompetencySerializer,
CompetencySerializer
CompetencySerializer,
EvaluationSerializer,
StudentEvaluationSerializer
)
from Common.models import Domain, Category
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from django.db.models import Q
from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency
from Subscriptions.util import getCurrentSchoolYear
from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
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):
@ -183,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)
@ -785,3 +831,179 @@ class EstablishmentCompetencyDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False)
except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# ===================== EVALUATIONS =====================
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EvaluationListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id')
school_class_id = request.GET.get('school_class')
period = request.GET.get('period')
if not establishment_id:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
evaluations = Evaluation.objects.filter(establishment_id=establishment_id)
if school_class_id:
evaluations = evaluations.filter(school_class_id=school_class_id)
if period:
evaluations = evaluations.filter(period=period)
serializer = EvaluationSerializer(evaluations, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = EvaluationSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EvaluationDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
serializer = EvaluationSerializer(evaluation)
return JsonResponse(serializer.data, safe=False)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = EvaluationSerializer(evaluation, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
evaluation.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# ===================== STUDENT EVALUATIONS =====================
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
student_id = request.GET.get('student_id')
evaluation_id = request.GET.get('evaluation_id')
period = request.GET.get('period')
school_class_id = request.GET.get('school_class_id')
student_evals = StudentEvaluation.objects.all()
if student_id:
student_evals = student_evals.filter(student_id=student_id)
if evaluation_id:
student_evals = student_evals.filter(evaluation_id=evaluation_id)
if period:
student_evals = student_evals.filter(evaluation__period=period)
if school_class_id:
student_evals = student_evals.filter(evaluation__school_class_id=school_class_id)
serializer = StudentEvaluationSerializer(student_evals, many=True)
return JsonResponse(serializer.data, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationBulkUpdateView(APIView):
"""
Mise à jour en masse des notes des élèves pour une évaluation.
Attendu dans le body :
[
{ "student_id": 1, "evaluation_id": 1, "score": 15.5, "comment": "", "is_absent": false },
...
]
"""
permission_classes = [IsAuthenticated]
def put(self, request):
data = JSONParser().parse(request)
if not isinstance(data, list):
data = [data]
updated = []
errors = []
for item in data:
student_id = item.get('student_id')
evaluation_id = item.get('evaluation_id')
if not student_id or not evaluation_id:
errors.append({'error': 'student_id et evaluation_id sont requis', 'item': item})
continue
try:
student_eval, created = StudentEvaluation.objects.update_or_create(
student_id=student_id,
evaluation_id=evaluation_id,
defaults={
'score': item.get('score'),
'comment': item.get('comment', ''),
'is_absent': item.get('is_absent', False)
}
)
updated.append(StudentEvaluationSerializer(student_eval).data)
except Exception as e:
errors.append({'error': str(e), 'item': item})
return JsonResponse({'updated': updated, 'errors': errors}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
serializer = StudentEvaluationSerializer(student_eval)
return JsonResponse(serializer.data, safe=False)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = StudentEvaluationSerializer(student_eval, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
student_eval.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)

View File

@ -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')
@ -589,6 +596,27 @@ class StudentCompetency(models.Model):
def __str__(self):
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
class StudentEvaluation(models.Model):
"""
Note d'un élève pour une évaluation.
Déplacé depuis School pour éviter les dépendances circulaires.
"""
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='evaluation_scores')
evaluation = models.ForeignKey('School.Evaluation', on_delete=models.CASCADE, related_name='student_scores')
score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
comment = models.TextField(blank=True)
is_absent = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('student', 'evaluation')
def __str__(self):
score_display = 'Absent' if self.is_absent else self.score
return f"{self.student} - {self.evaluation.name}: {score_display}"
####### Parent files templates (par dossier d'inscription) #######
class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)

View File

@ -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)

View File

@ -13,8 +13,11 @@ def run_command(command):
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
#flush_data=True
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
migrate_data=True
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
watch_mode=True
collect_static_cmd = [
["python", "manage.py", "collectstatic", "--noinput"]

91
CLAUDE.md Normal file
View File

@ -0,0 +1,91 @@
# CLAUDE.md — N3WT-SCHOOL
Instructions permanentes pour Claude Code sur ce projet.
## Architecture
- **Backend** : Python / Django — dossier `Back-End/`
- **Frontend** : Next.js 14 (App Router) — dossier `Front-End/`
- **Tests frontend** : `Front-End/src/test/`
- **CSS** : Tailwind CSS 3 + `@tailwindcss/forms`
## Design System
Le design system complet est dans [`docs/design-system.md`](docs/design-system.md). À lire et appliquer systématiquement.
### Tokens de couleur (Tailwind)
| Token | Hex | Usage |
|-------------|-----------|------------------------------------|
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
| `secondary` | `#064E3B` | Hover, accents sombres |
| `tertiary` | `#10B981` | Badges, icônes d'accent |
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
> **Règle absolue** : ne jamais utiliser `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens ci-dessus.
### Typographie
- `font-headline` → titres `h1`/`h2`/`h3` (Manrope)
- `font-body` → texte courant, défaut sur `<body>` (Inter)
- `font-label` → boutons, labels de form (Inter)
### Arrondi & Espacement
- Arrondi par défaut : `rounded` (4px)
- Espacement : grille 4px/8px — pas de valeurs arbitraires
- Mode : **light uniquement**, pas de dark mode
## Conventions de code
### Frontend (Next.js)
- **Composants** : React fonctionnels, pas de classes
- **Styles** : Tailwind CSS uniquement — pas de CSS inline sauf animations
- **Icônes** : `lucide-react`
- **i18n** : `next-intl` — toutes les chaînes UI via `useTranslations()`
- **Formulaires** : `react-hook-form`
- **Imports** : alias `@/` pointe vers `Front-End/src/`
### Qualité
- Linting : ESLint (`npm run lint` dans `Front-End/`)
- Format : Prettier
- Tests : Jest + React Testing Library (`Front-End/src/test/`)
## Gestion des branches
- Base : `develop`
- Nomenclature : `<type>-<nom_ticket>-<numero>` (ex: `feat-ma-feature-1234`)
- Types : `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
## Icônes
Utiliser **uniquement `lucide-react`** — jamais d'autre bibliothèque d'icônes.
```jsx
import { Home, Plus } from 'lucide-react';
<Home size={20} className="text-primary" />
```
## Responsive & PWA
- **Mobile-first** : styles de base = mobile, breakpoints `sm:`/`md:`/`lg:` pour agrandir.
- Touch targets ≥ 44px (`min-h-[44px]`) sur tous les éléments interactifs.
- Pas d'interactions uniquement au `:hover` — prévoir l'équivalent tactile.
- Les tableaux utilisent la classe `responsive-table` sur mobile.
## Réutilisation des composants
Avant de créer un composant, **vérifier `Front-End/src/components/`**.
Composants disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
## À éviter
- Ne pas ajouter de dépendances inutiles
- Ne pas modifier `package-lock.json` / `yarn.lock` manuellement
- Ne pas committer sans avoir vérifié ESLint et les tests
- Ne pas utiliser de CSS arbitraire (`p-[13px]`) sauf cas justifié
- Ne pas ajouter de support dark mode
- Ne pas utiliser d'autres bibliothèques d'icônes que `lucide-react`
- Ne pas créer un composant qui existe déjà dans `components/`

View File

@ -5,9 +5,11 @@ import SelectChoice from '@/components/Form/SelectChoice';
import Attendance from '@/components/Grades/Attendance';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import { EvaluationStudentView } from '@/components/Evaluation';
import Button from '@/components/Form/Button';
import logger from '@/utils/logger';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import {
fetchStudents,
fetchStudentCompetencies,
@ -15,9 +17,14 @@ import {
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import {
fetchEvaluations,
fetchStudentEvaluations,
updateStudentEvaluation,
} from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, ArrowLeft } from 'lucide-react';
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
@ -46,6 +53,10 @@ export default function StudentGradesPage() {
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]);
// Evaluation states
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
@ -135,6 +146,38 @@ export default function StudentGradesPage() {
}
}, [selectedEstablishmentId]);
// Load evaluations for the student
useEffect(() => {
if (
student?.associated_class_id &&
selectedPeriod &&
selectedEstablishmentId
) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
// Load evaluations for the class
fetchEvaluations(
selectedEstablishmentId,
student.associated_class_id,
periodString
)
.then((data) => setEvaluations(data))
.catch((error) =>
logger.error('Erreur lors du fetch des évaluations:', error)
);
// Load student's evaluation scores
fetchStudentEvaluations(studentId, null, periodString, null)
.then((data) => setStudentEvaluationsData(data))
.catch((error) =>
logger.error('Erreur lors du fetch des notes:', error)
);
}
}, [student, selectedPeriod, selectedEstablishmentId]);
const absences = React.useMemo(() => {
return allAbsences
.filter((a) => a.student === studentId)
@ -152,8 +195,12 @@ export default function StudentGradesPage() {
const handleToggleJustify = (absence) => {
const newReason =
absence.type === 'Absence'
? absence.justified ? 2 : 1
: absence.justified ? 4 : 3;
? absence.justified
? 2
: 1
: absence.justified
? 4
: 3;
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.then(() => {
@ -163,7 +210,9 @@ export default function StudentGradesPage() {
)
);
})
.catch((e) => logger.error('Erreur lors du changement de justification', e));
.catch((e) =>
logger.error('Erreur lors du changement de justification', e)
);
};
const handleDeleteAbsence = (absence) => {
@ -176,6 +225,26 @@ export default function StudentGradesPage() {
);
};
const handleUpdateGrade = async (studentEvalId, data) => {
try {
await updateStudentEvaluation(studentEvalId, data, csrfToken);
// Reload student evaluations
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const updatedData = await fetchStudentEvaluations(
studentId,
null,
periodString,
null
);
setStudentEvaluationsData(updatedData);
} catch (error) {
logger.error('Erreur lors de la modification de la note:', error);
}
};
return (
<div className="p-4 md:p-8 space-y-6">
{/* Header */}
@ -195,7 +264,7 @@ export default function StudentGradesPage() {
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
/>
@ -280,6 +349,22 @@ export default function StudentGradesPage() {
<div>
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
{/* Évaluations par matière */}
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center gap-2 mb-4">
<BookOpen className="w-6 h-6 text-emerald-600" />
<h2 className="text-xl font-semibold text-gray-800">
Évaluations par matière
</h2>
</div>
<EvaluationStudentView
evaluations={evaluations}
studentEvaluations={studentEvaluationsData}
editable={true}
onUpdateGrade={handleUpdateGrade}
/>
</div>
</div>
</div>
);

View File

@ -1,45 +1,79 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Award, Eye, Search } 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';
import {
BASE_URL,
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import {
fetchStudents,
fetchStudentCompetencies,
fetchAbsences,
} from '@/app/actions/subscriptionAction';
import {
fetchStudentEvaluations,
updateStudentEvaluation,
deleteStudentEvaluation,
} from '@/app/actions/schoolAction';
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 '';
}
function calcPercent(data) {
function calcCompetencyStats(data) {
if (!data?.data) return null;
const scores = [];
data.data.forEach((d) =>
d.categories.forEach((c) =>
c.competences.forEach((comp) => scores.push(comp.score ?? 0))
c.competences.forEach((comp) => scores.push(comp.score))
)
);
if (!scores.length) return null;
return Math.round(
(scores.filter((s) => s === 3).length / scores.length) * 100
);
const total = scores.length;
return {
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
inProgress: Math.round(
(scores.filter((s) => s === 2).length / total) * 100
),
notAcquired: Math.round(
(scores.filter((s) => s === 1).length / total) * 100
),
notEvaluated: Math.round(
(scores.filter((s) => s === null || s === undefined || s === 0).length /
total) *
100
),
};
}
function getPeriodColumns(frequency) {
@ -58,6 +92,29 @@ function getPeriodColumns(frequency) {
return [];
}
const COMPETENCY_COLUMNS = [
{
key: 'acquired',
label: 'Acquises',
color: 'bg-emerald-100 text-emerald-700',
},
{
key: 'inProgress',
label: 'En cours',
color: 'bg-yellow-100 text-yellow-700',
},
{
key: 'notAcquired',
label: 'Non acquises',
color: 'bg-red-100 text-red-600',
},
{
key: 'notEvaluated',
label: 'Non évaluées',
color: 'bg-gray-100 text-gray-600',
},
];
function getCurrentPeriodValue(frequency) {
const periods =
{
@ -81,18 +138,19 @@ function getCurrentPeriodValue(frequency) {
return current?.value ?? null;
}
function PercentBadge({ value, loading }) {
function PercentBadge({ value, loading, color }) {
if (loading) return <span className="text-gray-300 text-xs"></span>;
if (value === null) return <span className="text-gray-400 text-xs"></span>;
const color =
value >= 75
const badgeColor =
color ||
(value >= 75
? 'bg-emerald-100 text-emerald-700'
: value >= 50
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-600';
: 'bg-red-100 text-red-600');
return (
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
>
{value}%
</span>
@ -101,6 +159,7 @@ function PercentBadge({ value, loading }) {
export default function Page() {
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
@ -111,17 +170,39 @@ export default function Page() {
const [statsMap, setStatsMap] = useState({});
const [statsLoading, setStatsLoading] = useState(false);
const [absencesMap, setAbsencesMap] = useState({});
const [gradesModalStudent, setGradesModalStudent] = useState(null);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [gradesLoading, setGradesLoading] = useState(false);
const [editingEvalId, setEditingEvalId] = useState(null);
const [editScore, setEditScore] = useState('');
const [editAbsent, setEditAbsent] = useState(false);
const periodColumns = getPeriodColumns(
// 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 = 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));
@ -136,9 +217,9 @@ export default function Page() {
setAbsencesMap(map);
})
.catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]);
}, [selectedEstablishmentId, selectedSchoolYear]);
// Fetch stats for all students × all periods
// Fetch stats for all students - aggregate all periods
useEffect(() => {
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
@ -147,7 +228,7 @@ export default function Page() {
const tasks = students.flatMap((student) =>
periodColumns.map(({ value: periodValue }) => {
const periodStr = getPeriodString(periodValue, frequency);
const periodStr = getPeriodString(periodValue, frequency, selectedSchoolYear);
return fetchStudentCompetencies(student.id, periodStr)
.then((data) => ({ studentId: student.id, periodValue, data }))
.catch(() => ({ studentId: student.id, periodValue, data: null }));
@ -156,20 +237,50 @@ export default function Page() {
Promise.all(tasks).then((results) => {
const map = {};
results.forEach(({ studentId, periodValue, data }) => {
if (!map[studentId]) map[studentId] = {};
map[studentId][periodValue] = calcPercent(data);
// Group by student and aggregate all competency scores across periods
const studentScores = {};
results.forEach(({ studentId, data }) => {
if (!studentScores[studentId]) studentScores[studentId] = [];
if (data?.data) {
data.data.forEach((d) =>
d.categories.forEach((c) =>
c.competences.forEach((comp) =>
studentScores[studentId].push(comp.score)
)
)
);
}
});
Object.keys(map).forEach((id) => {
const vals = Object.values(map[id]).filter((v) => v !== null);
map[id].global = vals.length
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
: null;
// Calculate stats for each student
Object.keys(studentScores).forEach((studentId) => {
const scores = studentScores[studentId];
if (!scores.length) {
map[studentId] = null;
} else {
const total = scores.length;
map[studentId] = {
acquired: Math.round(
(scores.filter((s) => s === 3).length / total) * 100
),
inProgress: Math.round(
(scores.filter((s) => s === 2).length / total) * 100
),
notAcquired: Math.round(
(scores.filter((s) => s === 1).length / total) * 100
),
notEvaluated: Math.round(
(scores.filter((s) => s === null || s === undefined || s === 0)
.length /
total) *
100
),
};
}
});
setStatsMap(map);
setStatsLoading(false);
});
}, [students, selectedEstablishmentEvaluationFrequency]);
}, [students, selectedEstablishmentEvaluationFrequency, selectedSchoolYear, periodColumns]);
const filteredStudents = students.filter(
(student) =>
@ -189,11 +300,135 @@ export default function Page() {
currentPage * ITEMS_PER_PAGE
);
const openGradesModal = (e, student) => {
e.stopPropagation();
setGradesModalStudent(student);
setGradesLoading(true);
fetchStudentEvaluations(student.id)
.then((data) => {
setStudentEvaluations(data || []);
setGradesLoading(false);
})
.catch((error) => {
logger.error('Error fetching student evaluations:', error);
setStudentEvaluations([]);
setGradesLoading(false);
});
};
const closeGradesModal = () => {
setGradesModalStudent(null);
setStudentEvaluations([]);
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 ?? '');
setEditAbsent(evalItem.is_absent ?? false);
};
const cancelEditingEval = () => {
setEditingEvalId(null);
setEditScore('');
setEditAbsent(false);
};
const handleSaveEval = async (evalItem) => {
try {
await updateStudentEvaluation(
evalItem.id,
{
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
},
csrfToken
);
// Update local state
setStudentEvaluations((prev) =>
prev.map((e) =>
e.id === evalItem.id
? {
...e,
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
}
: e
)
);
cancelEditingEval();
} catch (error) {
logger.error('Error updating evaluation:', error);
}
};
const handleDeleteEval = async (evalItem) => {
if (!confirm('Supprimer cette note ?')) return;
try {
await deleteStudentEvaluation(evalItem.id, csrfToken);
setStudentEvaluations((prev) => prev.filter((e) => e.id !== evalItem.id));
} catch (error) {
logger.error('Error deleting evaluation:', error);
}
};
// Group evaluations by subject
const groupedBySubject = studentEvaluations.reduce((acc, evalItem) => {
const subjectName = evalItem.speciality_name || 'Sans matière';
const subjectColor = evalItem.speciality_color || '#6B7280';
if (!acc[subjectName]) {
acc[subjectName] = { color: subjectColor, evaluations: [] };
}
acc[subjectName].evaluations.push(evalItem);
return acc;
}, {});
const handleEvaluer = (e, studentId) => {
e.stopPropagation();
const periodStr = getPeriodString(
currentPeriodValue,
selectedEstablishmentEvaluationFrequency
selectedEstablishmentEvaluationFrequency,
selectedSchoolYear
);
router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
@ -205,8 +440,10 @@ export default function Page() {
{ name: 'Élève', transform: () => null },
{ name: 'Niveau', transform: () => null },
{ name: 'Classe', transform: () => null },
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
{ name: 'Stat globale', transform: () => null },
...COMPETENCY_COLUMNS.map(({ label }) => ({
name: label,
transform: () => null,
})),
{ name: 'Absences', transform: () => null },
{ name: 'Actions', transform: () => null },
];
@ -219,13 +456,13 @@ export default function Page() {
<div className="flex justify-center items-center">
{student.photo ? (
<a
href={`${BASE_URL}${student.photo}`}
href={getSecureFileUrl(student.photo)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -233,7 +470,8 @@ export default function Page() {
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
<span className="text-gray-500 text-sm font-semibold">
{student.first_name?.[0]}{student.last_name?.[0]}
{student.first_name?.[0]}
{student.last_name?.[0]}
</span>
</div>
)}
@ -252,7 +490,9 @@ export default function Page() {
<button
onClick={(e) => {
e.stopPropagation();
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`);
router.push(
`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`
);
}}
className="text-emerald-700 hover:underline font-medium"
>
@ -261,13 +501,6 @@ export default function Page() {
) : (
student.associated_class_name
);
case 'Stat globale':
return (
<PercentBadge
value={stats.global ?? null}
loading={statsLoading && !('global' in stats)}
/>
);
case 'Absences':
return absencesMap[student.id] ? (
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
@ -280,13 +513,24 @@ export default function Page() {
return (
<div className="flex items-center justify-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }}
onClick={(e) => {
e.stopPropagation();
router.push(`/admin/grades/${student.id}`);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
title="Voir la fiche"
>
<Eye size={14} />
Fiche
</button>
<button
onClick={(e) => openGradesModal(e, student)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition whitespace-nowrap"
title="Voir les notes"
>
<BarChart2 size={14} />
Notes
</button>
<button
onClick={(e) => handleEvaluer(e, student.id)}
disabled={!currentPeriodValue}
@ -299,12 +543,13 @@ export default function Page() {
</div>
);
default: {
const col = periodColumns.find((c) => c.label === column);
const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
if (col) {
return (
<PercentBadge
value={stats[col.value] ?? null}
loading={statsLoading && !(col.value in stats)}
value={stats?.[col.key] ?? null}
loading={statsLoading && !stats}
color={col.color}
/>
);
}
@ -320,7 +565,14 @@ export default function Page() {
title="Suivi pédagogique"
description="Suivez le parcours d'un élève"
/>
<div className="relative flex-grow max-w-md">
<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}
@ -333,6 +585,15 @@ export default function Page() {
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
data={pagedStudents}
@ -346,6 +607,291 @@ export default function Page() {
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
}
/>
{/* Modal Notes par matière */}
{gradesModalStudent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<div>
<h2 className="text-lg font-semibold text-gray-800">
Notes de {gradesModalStudent.first_name}{' '}
{gradesModalStudent.last_name}
</h2>
<p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name ||
'Classe non assignée'}
</p>
</div>
<button
onClick={closeGradesModal}
className="p-2 hover:bg-gray-200 rounded-full transition"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{gradesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
</div>
) : Object.keys(groupedBySubject).length === 0 ? (
<div className="text-center py-12 text-gray-400">
Aucune note enregistrée pour cet élève.
</div>
) : (
<div className="space-y-6">
{/* Résumé des moyennes */}
{(() => {
const subjectAverages = Object.entries(groupedBySubject)
.map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) /
scores.length
: null;
return { subject, color, avg };
})
.filter((s) => s.avg !== null && !isNaN(s.avg));
const overallAvg = subjectAverages.length
? (
subjectAverages.reduce((sum, s) => sum + s.avg, 0) /
subjectAverages.length
).toFixed(1)
: null;
return (
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-600">
Résumé
</span>
{overallAvg !== null && (
<span className="text-lg font-bold text-emerald-700">
Moyenne générale : {overallAvg}/20
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
{subjectAverages.map(({ subject, color, avg }) => (
<div
key={subject}
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-full border shadow-sm"
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="text-sm text-gray-700">
{subject}
</span>
<span className="text-sm font-semibold text-gray-800">
{avg.toFixed(1)}
</span>
</div>
))}
</div>
</div>
);
})()}
{Object.entries(groupedBySubject).map(
([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? (
scores.reduce((sum, s) => sum + s, 0) /
scores.length
).toFixed(1)
: null;
return (
<div
key={subject}
className="border rounded-lg overflow-hidden"
>
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">
{subject}
</span>
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
Moyenne : {avg}
</span>
)}
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Évaluation
</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Période
</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">
Note
</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
Actions
</th>
</tr>
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr
key={evalItem.id}
className="border-t hover:bg-gray-50"
>
<td className="px-4 py-2 text-gray-700">
{evalItem.evaluation_name || 'Évaluation'}
</td>
<td className="px-4 py-2 text-gray-500">
{evalItem.period || '—'}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked)
setEditScore('');
}}
/>
Abs
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) =>
setEditScore(e.target.value)
}
min="0"
max={evalItem.max_score || 20}
step="0.5"
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
/>
)}
<span className="text-gray-500">
/{evalItem.max_score || 20}
</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">
Absent
</span>
) : evalItem.score !== null ? (
<span className="font-semibold text-gray-800">
{evalItem.score}/
{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
handleSaveEval(evalItem)
}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={14} />
</button>
<button
onClick={cancelEditingEval}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
startEditingEval(evalItem)
}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() =>
handleDeleteEval(evalItem)
}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<button
onClick={closeGradesModal}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,10 +1,10 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react';
import { Users, Layers, CheckCircle, Clock, XCircle, ClipboardList, Plus } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import { fetchClasse } from '@/app/actions/schoolAction';
import { fetchClasse, fetchSpecialities, fetchEvaluations, createEvaluation, updateEvaluation, deleteEvaluation, fetchStudentEvaluations, saveStudentEvaluations, deleteStudentEvaluation } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext';
@ -17,10 +17,12 @@ import {
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
import dayjs from 'dayjs';
export default function Page() {
const searchParams = useSearchParams();
@ -38,8 +40,53 @@ export default function Page() {
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
// Tab system
const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations'
// Evaluation states
const [specialities, setSpecialities] = useState([]);
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [showEvaluationForm, setShowEvaluationForm] = useState(false);
const [selectedEvaluation, setSelectedEvaluation] = useState(null);
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [editingEvaluation, setEditingEvaluation] = useState(null);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
// Périodes selon la fréquence d'évaluation
const getPeriods = () => {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: `T1_${schoolYear}` },
{ label: 'Trimestre 2', value: `T2_${schoolYear}` },
{ label: 'Trimestre 3', value: `T3_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: `S1_${schoolYear}` },
{ label: 'Semestre 2', value: `S2_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: `A_${schoolYear}` }];
}
return [];
};
// Auto-select current period
useEffect(() => {
const periods = getPeriods();
if (periods.length > 0 && !selectedPeriod) {
setSelectedPeriod(periods[0].value);
}
}, [selectedEstablishmentEvaluationFrequency]);
// AbsenceMoment constants
const AbsenceMoment = {
@ -158,6 +205,87 @@ export default function Page() {
}
}, [filteredStudents, fetchedAbsences]);
// Load specialities for evaluations
useEffect(() => {
if (selectedEstablishmentId) {
fetchSpecialities(selectedEstablishmentId)
.then((data) => setSpecialities(data))
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
}
}, [selectedEstablishmentId]);
// Load evaluations when tab is active and period is selected
useEffect(() => {
if (activeTab === 'evaluations' && selectedEstablishmentId && schoolClassId && selectedPeriod) {
fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod)
.then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des évaluations:', error));
}
}, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]);
// Load student evaluations when grading
useEffect(() => {
if (selectedEvaluation && schoolClassId) {
fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId)
.then((data) => setStudentEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des notes:', error));
}
}, [selectedEvaluation, schoolClassId]);
// Handlers for evaluations
const handleCreateEvaluation = async (data) => {
try {
await createEvaluation(data, csrfToken);
showNotification('Évaluation créée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la création:', error);
showNotification('Erreur lors de la création', 'error', 'Erreur');
}
};
const handleEditEvaluation = (evaluation) => {
setEditingEvaluation(evaluation);
setShowEvaluationForm(true);
};
const handleUpdateEvaluation = async (data) => {
try {
await updateEvaluation(editingEvaluation.id, data, csrfToken);
showNotification('Évaluation modifiée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
setEditingEvaluation(null);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la modification:', error);
showNotification('Erreur lors de la modification', 'error', 'Erreur');
}
};
const handleDeleteEvaluation = async (evaluationId) => {
await deleteEvaluation(evaluationId, csrfToken);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
};
const handleSaveGrades = async (gradesData) => {
await saveStudentEvaluations(gradesData, csrfToken);
// Reload student evaluations
const updatedStudentEvaluations = await fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId);
setStudentEvaluations(updatedStudentEvaluations);
};
const handleDeleteGrade = async (studentEvalId) => {
await deleteStudentEvaluation(studentEvalId, csrfToken);
setStudentEvaluations((prev) => prev.filter((se) => se.id !== studentEvalId));
};
const handleLevelClick = (label) => {
setSelectedLevels(
(prev) =>
@ -474,6 +602,41 @@ export default function Page() {
</div>
</div>
{/* Tabs Navigation */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('attendance')}
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
activeTab === 'attendance'
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<Clock className="w-5 h-5" />
Appel du jour
</div>
</button>
<button
onClick={() => setActiveTab('evaluations')}
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
activeTab === 'evaluations'
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<ClipboardList className="w-5 h-5" />
Évaluations
</div>
</button>
</div>
</div>
{/* Tab Content: Attendance */}
{activeTab === 'attendance' && (
<>
{/* Affichage de la date du jour */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
@ -728,6 +891,84 @@ export default function Page() {
]}
data={filteredStudents} // Utiliser les élèves filtrés
/>
</>
)}
{/* Tab Content: Evaluations */}
{activeTab === 'evaluations' && (
<div className="space-y-4">
{/* Header avec sélecteur de période et bouton d'ajout */}
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ClipboardList className="w-6 h-6 text-emerald-600" />
<h2 className="text-lg font-semibold text-gray-800">
Évaluations de la classe
</h2>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="w-48">
<SelectChoice
name="period"
placeHolder="Période"
choices={getPeriods()}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(e.target.value)}
/>
</div>
<Button
primary
text="Nouvelle évaluation"
icon={<Plus size={16} />}
onClick={() => setShowEvaluationForm(true)}
/>
</div>
</div>
</div>
{/* Formulaire de création/édition d'évaluation */}
{showEvaluationForm && (
<EvaluationForm
specialities={specialities}
period={selectedPeriod}
schoolClassId={parseInt(schoolClassId)}
establishmentId={selectedEstablishmentId}
initialValues={editingEvaluation}
onSubmit={editingEvaluation ? handleUpdateEvaluation : handleCreateEvaluation}
onCancel={() => {
setShowEvaluationForm(false);
setEditingEvaluation(null);
}}
/>
)}
{/* Liste des évaluations */}
<div className="bg-white p-4 rounded-lg shadow-md">
<EvaluationList
evaluations={evaluations}
onDelete={handleDeleteEvaluation}
onEdit={handleEditEvaluation}
onGradeStudents={(evaluation) => setSelectedEvaluation(evaluation)}
/>
</div>
{/* Modal de notation */}
{selectedEvaluation && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
<EvaluationGradeTable
evaluation={selectedEvaluation}
students={filteredStudents}
studentEvaluations={studentEvaluations}
onSave={handleSaveGrades}
onClose={() => setSelectedEvaluation(null)}
onDeleteGrade={handleDeleteGrade}
/>
</div>
</div>
)}
</div>
)}
{/* Popup */}
<Popup

View File

@ -34,12 +34,13 @@ import {
import {
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters
fetchRegistrationParentFileMasters,
} from '@/app/actions/registerFileGroupAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { useNotification } from '@/context/NotificationContext';
export default function CreateSubscriptionPage() {
@ -181,7 +182,9 @@ export default function CreateSubscriptionPage() {
formDataRef.current = formData;
}, [formData]);
useEffect(() => { setStudentsPage(1); }, [students]);
useEffect(() => {
setStudentsPage(1);
}, [students]);
useEffect(() => {
if (!formData.guardianEmail) {
@ -714,7 +717,10 @@ export default function CreateSubscriptionPage() {
};
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE);
const pagedStudents = students.slice(
(studentsPage - 1) * ITEMS_PER_PAGE,
studentsPage * ITEMS_PER_PAGE
);
if (isLoading === true) {
return <Loader />; // Affichez le composant Loader
@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
<div className="flex justify-center items-center">
{row.photo ? (
<a
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.photo}`}
src={getSecureFileUrl(row.photo)}
alt={`${row.first_name} ${row.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>

View File

@ -19,6 +19,7 @@ import {
Upload,
Eye,
XCircle,
Download,
} from 'lucide-react';
import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext';
@ -36,8 +37,8 @@ import {
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
BASE_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext';
@ -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');
@ -112,15 +114,29 @@ export default function Page({ params: { locale } }) {
// Valide le refus
const handleRefuse = () => {
if (!refuseReason.trim()) {
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
showNotification(
'Merci de préciser la raison du refus.',
'error',
'Erreur'
);
return;
}
const formData = new FormData();
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
formData.append(
'data',
JSON.stringify({
status: RegistrationFormStatus.STATUS_ARCHIVED,
notes: refuseReason,
})
);
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
.then(() => {
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
showNotification(
'Le dossier a été refusé et archivé.',
'success',
'Succès'
);
setReloadFetch(true);
setIsRefusePopupOpen(false);
})
@ -149,6 +165,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);
};
@ -668,12 +727,12 @@ export default function Page({ params: { locale } }) {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
alt={`${row.student.first_name} ${row.student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -839,18 +898,28 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange}
/>
</div>
<div className="flex items-center gap-2 ml-4">
<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>
{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 ml-4"
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">
<DjangoCSRFToken csrfToken={csrfToken} />
@ -898,7 +967,9 @@ export default function Page({ params: { locale } }) {
isOpen={isRefusePopupOpen}
message={
<div>
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
<div className="mb-2 font-semibold">
Veuillez indiquer la raison du refus :
</div>
<Textarea
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}

View File

@ -19,7 +19,7 @@ import {
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext';
@ -139,12 +139,12 @@ export default function ParentHomePage() {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
alt={`${row.student.first_name} ${row.student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -225,7 +225,7 @@ export default function ParentHomePage() {
<Eye className="h-5 w-5" />
</button>
<a
href={`${BASE_URL}${row.sepa_file}`}
href={getSecureFileUrl(row.sepa_file)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"

View File

@ -9,6 +9,9 @@ import {
BE_SCHOOL_PAYMENT_MODES_URL,
BE_SCHOOL_ESTABLISHMENT_URL,
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';
@ -44,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) => {
@ -132,3 +140,71 @@ export const removeDatas = (url, id, csrfToken) => {
headers: { 'X-CSRFToken': csrfToken },
});
};
// ===================== EVALUATIONS =====================
export const fetchEvaluations = (establishmentId, schoolClassId = null, period = null) => {
let url = `${BE_SCHOOL_EVALUATIONS_URL}?establishment_id=${establishmentId}`;
if (schoolClassId) url += `&school_class=${schoolClassId}`;
if (period) url += `&period=${period}`;
return fetchWithAuth(url);
};
export const createEvaluation = (data, csrfToken) => {
return fetchWithAuth(BE_SCHOOL_EVALUATIONS_URL, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const updateEvaluation = (id, data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const deleteEvaluation = (id, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken },
});
};
// ===================== STUDENT EVALUATIONS =====================
export const fetchStudentEvaluations = (studentId = null, evaluationId = null, period = null, schoolClassId = null) => {
let url = `${BE_SCHOOL_STUDENT_EVALUATIONS_URL}?`;
const params = [];
if (studentId) params.push(`student_id=${studentId}`);
if (evaluationId) params.push(`evaluation_id=${evaluationId}`);
if (period) params.push(`period=${period}`);
if (schoolClassId) params.push(`school_class_id=${schoolClassId}`);
url += params.join('&');
return fetchWithAuth(url);
};
export const saveStudentEvaluations = (data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/bulk`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const updateStudentEvaluation = (id, data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const deleteStudentEvaluation = (id, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken },
});
};

View File

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

View File

@ -1,10 +1,23 @@
import React from 'react';
import { getMessages } from 'next-intl/server';
import { Inter, Manrope } from 'next/font/google';
import Providers from '@/components/Providers';
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
import '@/css/tailwind.css';
import { headers } from 'next/headers';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const manrope = Manrope({
subsets: ['latin'],
variable: '--font-manrope',
display: 'swap',
});
export const metadata = {
title: 'N3WT-SCHOOL',
description: "Gestion de l'école",
@ -36,7 +49,7 @@ export default async function RootLayout({ children, params }) {
return (
<html lang={locale}>
<body className="p-0 m-0">
<body className={`p-0 m-0 font-body ${inter.variable} ${manrope.variable}`}>
<Providers messages={messages} locale={locale} session={params.session}>
{children}
</Providers>

View File

@ -8,6 +8,7 @@ import {
Archive,
AlertCircle,
} from 'lucide-react';
import { getSecureFileUrl } from '@/utils/fileUrl';
const FileAttachment = ({
fileName,
@ -16,6 +17,7 @@ const FileAttachment = ({
fileUrl,
onDownload = null,
}) => {
const secureUrl = getSecureFileUrl(fileUrl);
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
@ -49,9 +51,9 @@ const FileAttachment = ({
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
} else if (secureUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.href = secureUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
@ -64,14 +66,14 @@ const FileAttachment = ({
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
{isImage && secureUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
src={secureUrl}
alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(fileUrl, '_blank')}
onClick={() => window.open(secureUrl, '_blank')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
<button

View File

@ -0,0 +1,158 @@
'use client';
import React, { useState, useEffect } from 'react';
import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/Form/SelectChoice';
import Button from '@/components/Form/Button';
import { Plus, Save, X } from 'lucide-react';
export default function EvaluationForm({
specialities,
period,
schoolClassId,
establishmentId,
initialValues,
onSubmit,
onCancel,
}) {
const isEditing = !!initialValues;
const [form, setForm] = useState({
name: '',
speciality: '',
date: '',
max_score: '20',
coefficient: '1',
description: '',
});
useEffect(() => {
if (initialValues) {
setForm({
name: initialValues.name || '',
speciality: initialValues.speciality?.toString() || '',
date: initialValues.date || '',
max_score: initialValues.max_score?.toString() || '20',
coefficient: initialValues.coefficient?.toString() || '1',
description: initialValues.description || '',
});
}
}, [initialValues]);
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!form.name.trim()) newErrors.name = 'Le nom est requis';
if (!form.speciality) newErrors.speciality = 'La matière est requise';
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit({
name: form.name,
speciality: Number(form.speciality),
school_class: schoolClassId,
establishment: establishmentId,
period: period,
date: form.date || null,
max_score: parseFloat(form.max_score) || 20,
coefficient: parseFloat(form.coefficient) || 1,
description: form.description,
});
};
return (
<form
onSubmit={handleSubmit}
className="space-y-4 p-4 bg-white rounded-lg border border-gray-200 shadow-sm"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-800">
{isEditing ? 'Modifier l\'évaluation' : 'Nouvelle évaluation'}
</h3>
<button
type="button"
onClick={onCancel}
className="p-1 hover:bg-gray-100 rounded"
>
<X size={20} className="text-gray-500" />
</button>
</div>
<InputText
name="name"
label="Nom de l'évaluation"
placeholder="Ex: Contrôle de mathématiques"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
errorMsg={errors.name}
required
/>
<SelectChoice
name="speciality"
label="Matière"
placeHolder="Sélectionner une matière"
choices={specialities.map((s) => ({ value: s.id, label: s.name }))}
selected={form.speciality}
callback={(e) => setForm({ ...form, speciality: e.target.value })}
errorMsg={errors.speciality}
required
/>
<InputText
name="date"
type="date"
label="Date de l'évaluation"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
/>
<div className="flex gap-4">
<div className="flex-1">
<InputText
name="max_score"
type="number"
label="Note maximale"
value={form.max_score}
onChange={(e) => setForm({ ...form, max_score: e.target.value })}
/>
</div>
<div className="flex-1">
<InputText
name="coefficient"
type="number"
label="Coefficient"
value={form.coefficient}
onChange={(e) => setForm({ ...form, coefficient: e.target.value })}
/>
</div>
</div>
<InputText
name="description"
label="Description (optionnel)"
placeholder="Détails de l'évaluation..."
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
<div className="flex gap-2 pt-2">
<Button
primary
type="submit"
text={isEditing ? 'Enregistrer' : 'Créer l\'évaluation'}
icon={isEditing ? <Save size={16} /> : <Plus size={16} />}
/>
<Button type="button" text="Annuler" onClick={onCancel} />
</div>
</form>
);
}

View File

@ -0,0 +1,299 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Save, X, UserX, Trash2 } from 'lucide-react';
import Button from '@/components/Form/Button';
import CheckBox from '@/components/Form/CheckBox';
import { useNotification } from '@/context/NotificationContext';
export default function EvaluationGradeTable({
evaluation,
students,
studentEvaluations,
onSave,
onClose,
onDeleteGrade,
}) {
const [grades, setGrades] = useState({});
const [isSaving, setIsSaving] = useState(false);
const { showNotification } = useNotification();
// Initialiser les notes à partir des données existantes
useEffect(() => {
const initialGrades = {};
students.forEach((student) => {
const existingEval = studentEvaluations.find(
(se) => se.student === student.id && se.evaluation === evaluation.id
);
initialGrades[student.id] = {
score: existingEval?.score ?? '',
comment: existingEval?.comment ?? '',
is_absent: existingEval?.is_absent ?? false,
};
});
setGrades(initialGrades);
}, [students, studentEvaluations, evaluation]);
const handleScoreChange = (studentId, value) => {
const numValue = value === '' ? '' : parseFloat(value);
if (value !== '' && (numValue < 0 || numValue > evaluation.max_score)) {
return;
}
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
score: value,
is_absent: false,
},
}));
};
const handleAbsentToggle = (studentId) => {
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
is_absent: !prev[studentId]?.is_absent,
score: !prev[studentId]?.is_absent ? '' : prev[studentId]?.score,
},
}));
};
const handleCommentChange = (studentId, value) => {
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
comment: value,
},
}));
};
const handleSave = async () => {
setIsSaving(true);
try {
const dataToSave = Object.entries(grades).map(([studentId, data]) => ({
student_id: parseInt(studentId),
evaluation_id: evaluation.id,
score: data.score === '' ? null : parseFloat(data.score),
comment: data.comment,
is_absent: data.is_absent,
}));
await onSave(dataToSave);
showNotification('Notes enregistrées avec succès', 'success', 'Succès');
} catch (error) {
showNotification('Erreur lors de la sauvegarde', 'error', 'Erreur');
} finally {
setIsSaving(false);
}
};
// Calculer les statistiques
const stats = React.useMemo(() => {
const validScores = Object.values(grades)
.filter((g) => g.score !== '' && !g.is_absent)
.map((g) => parseFloat(g.score));
if (validScores.length === 0) return null;
const sum = validScores.reduce((a, b) => a + b, 0);
const avg = sum / validScores.length;
const min = Math.min(...validScores);
const max = Math.max(...validScores);
const absentCount = Object.values(grades).filter((g) => g.is_absent).length;
return { avg, min, max, count: validScores.length, absentCount };
}, [grades]);
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-lg">
{/* Header */}
<div className="p-4 border-b border-gray-200 bg-gray-50 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-gray-800">
{evaluation.name}
</h3>
<div className="text-sm text-gray-500 flex gap-3">
<span>{evaluation.speciality_name}</span>
<span></span>
<span>Note max: {evaluation.max_score}</span>
{evaluation.date && (
<>
<span></span>
<span>
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
</span>
</>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-full"
>
<X size={20} className="text-gray-500" />
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-[60vh]">
<table className="min-w-full">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
Élève
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-32">
Note / {evaluation.max_score}
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-24">
Absent
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
Commentaire
</th>
{onDeleteGrade && (
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-20">
Actions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{students.map((student) => {
const studentGrade = grades[student.id] || {};
const isAbsent = studentGrade.is_absent;
const existingEval = studentEvaluations.find(
(se) => se.student === student.id && se.evaluation === evaluation.id
);
return (
<tr
key={student.id}
className={`hover:bg-gray-50 ${isAbsent ? 'bg-red-50' : ''}`}
>
<td className="px-4 py-3">
<div className="font-medium text-gray-800">
{student.last_name} {student.first_name}
</div>
</td>
<td className="px-4 py-3 text-center">
<input
type="number"
step="0.5"
min="0"
max={evaluation.max_score}
value={studentGrade.score ?? ''}
onChange={(e) =>
handleScoreChange(student.id, e.target.value)
}
disabled={isAbsent}
className={`w-20 px-2 py-1 text-center border rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 ${
isAbsent
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'border-gray-300'
}`}
/>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleAbsentToggle(student.id)}
className={`p-2 rounded ${
isAbsent
? 'bg-red-100 text-red-600'
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
}`}
title={isAbsent ? 'Marquer présent' : 'Marquer absent'}
>
<UserX size={18} />
</button>
</td>
<td className="px-4 py-3">
<input
type="text"
value={studentGrade.comment ?? ''}
onChange={(e) =>
handleCommentChange(student.id, e.target.value)
}
placeholder="Commentaire..."
className="w-full px-2 py-1 border border-gray-300 rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</td>
{onDeleteGrade && (
<td className="px-4 py-3 text-center">
{existingEval && (
<button
onClick={() => {
if (confirm('Supprimer cette note ?')) {
onDeleteGrade(existingEval.id);
setGrades((prev) => ({
...prev,
[student.id]: { score: '', comment: '', is_absent: false },
}));
}
}}
className="p-2 text-red-600 hover:bg-red-50 rounded"
title="Supprimer la note"
>
<Trash2 size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Footer avec statistiques et boutons */}
<div className="p-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="flex items-center justify-between">
{/* Statistiques */}
{stats && (
<div className="flex gap-4 text-sm text-gray-600">
<span>
Moyenne:{' '}
<span className="font-semibold text-emerald-600">
{stats.avg.toFixed(2)}
</span>
</span>
<span>
Min:{' '}
<span className="font-semibold text-red-600">{stats.min}</span>
</span>
<span>
Max:{' '}
<span className="font-semibold text-green-600">{stats.max}</span>
</span>
<span>
Notés: {stats.count}/{students.length}
</span>
{stats.absentCount > 0 && (
<span className="text-red-600">
Absents: {stats.absentCount}
</span>
)}
</div>
)}
{/* Boutons */}
<div className="flex gap-2">
<Button text="Fermer" onClick={onClose} />
<Button
primary
text={isSaving ? 'Enregistrement...' : 'Enregistrer'}
icon={<Save size={16} />}
onClick={handleSave}
disabled={isSaving}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
'use client';
import React, { useState } from 'react';
import { Trash2, Edit2, ClipboardList, ChevronDown, ChevronUp } from 'lucide-react';
import Button from '@/components/Form/Button';
import Popup from '@/components/Popup';
import { useNotification } from '@/context/NotificationContext';
export default function EvaluationList({
evaluations,
onDelete,
onEdit,
onGradeStudents,
}) {
const [expandedId, setExpandedId] = useState(null);
const [deletePopupVisible, setDeletePopupVisible] = useState(false);
const [evaluationToDelete, setEvaluationToDelete] = useState(null);
const { showNotification } = useNotification();
const handleDeleteClick = (evaluation) => {
setEvaluationToDelete(evaluation);
setDeletePopupVisible(true);
};
const handleConfirmDelete = () => {
if (evaluationToDelete && onDelete) {
onDelete(evaluationToDelete.id)
.then(() => {
showNotification('Évaluation supprimée avec succès', 'success', 'Succès');
setDeletePopupVisible(false);
setEvaluationToDelete(null);
})
.catch((error) => {
showNotification('Erreur lors de la suppression', 'error', 'Erreur');
});
}
};
// Grouper les évaluations par matière
const groupedBySpeciality = evaluations.reduce((acc, ev) => {
const key = ev.speciality_name || 'Sans matière';
if (!acc[key]) {
acc[key] = {
name: key,
color: ev.speciality_color || '#6B7280',
evaluations: [],
};
}
acc[key].evaluations.push(ev);
return acc;
}, {});
if (evaluations.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
Aucune évaluation créée pour cette période
</div>
);
}
return (
<div className="space-y-4">
{Object.values(groupedBySpeciality).map((group) => (
<div
key={group.name}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer"
onClick={() =>
setExpandedId(expandedId === group.name ? null : group.name)
}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span className="font-medium text-gray-800">{group.name}</span>
<span className="text-sm text-gray-500">
({group.evaluations.length} évaluation
{group.evaluations.length > 1 ? 's' : ''})
</span>
</div>
{expandedId === group.name ? (
<ChevronUp size={20} className="text-gray-500" />
) : (
<ChevronDown size={20} className="text-gray-500" />
)}
</div>
{expandedId === group.name && (
<div className="divide-y divide-gray-100">
{group.evaluations.map((evaluation) => (
<div
key={evaluation.id}
className="p-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium text-gray-700">
{evaluation.name}
</div>
<div className="text-sm text-gray-500 flex gap-3">
{evaluation.date && (
<span>
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
</span>
)}
<span>Note max: {evaluation.max_score}</span>
<span>Coef: {evaluation.coefficient}</span>
</div>
</div>
<div className="flex gap-2">
<Button
primary
onClick={() => onGradeStudents(evaluation)}
icon={<ClipboardList size={16} />}
text="Noter"
title="Noter les élèves"
/>
<button
onClick={() => onEdit && onEdit(evaluation)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDeleteClick(evaluation)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
<Popup
isOpen={deletePopupVisible}
message={`Êtes-vous sûr de vouloir supprimer l'évaluation "${evaluationToDelete?.name}" ?`}
onConfirm={handleConfirmDelete}
onCancel={() => {
setDeletePopupVisible(false);
setEvaluationToDelete(null);
}}
/>
</div>
);
}

View File

@ -0,0 +1,298 @@
'use client';
import React, { useState } from 'react';
import { BookOpen, TrendingUp, TrendingDown, Minus, Pencil, Trash2, Save, X } from 'lucide-react';
export default function EvaluationStudentView({
evaluations,
studentEvaluations,
onUpdateGrade,
onDeleteGrade,
editable = false
}) {
const [editingId, setEditingId] = useState(null);
const [editScore, setEditScore] = useState('');
const [editComment, setEditComment] = useState('');
const [editAbsent, setEditAbsent] = useState(false);
if (!evaluations || evaluations.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
Aucune évaluation pour cette période
</div>
);
}
const startEdit = (ev, studentEval) => {
setEditingId(ev.id);
setEditScore(studentEval?.score ?? '');
setEditComment(studentEval?.comment ?? '');
setEditAbsent(studentEval?.is_absent ?? false);
};
const cancelEdit = () => {
setEditingId(null);
setEditScore('');
setEditComment('');
setEditAbsent(false);
};
const handleSaveEdit = async (ev, studentEval) => {
if (onUpdateGrade && studentEval) {
await onUpdateGrade(studentEval.id, {
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)),
comment: editComment,
is_absent: editAbsent,
});
}
cancelEdit();
};
const handleDelete = async (studentEval) => {
if (onDeleteGrade && studentEval && confirm('Supprimer cette note ?')) {
await onDeleteGrade(studentEval.id);
}
};
// Grouper les évaluations par matière
const groupedBySpeciality = evaluations.reduce((acc, ev) => {
const key = ev.speciality_name || 'Sans matière';
if (!acc[key]) {
acc[key] = {
name: key,
color: ev.speciality_color || '#6B7280',
evaluations: [],
totalScore: 0,
totalMaxScore: 0,
totalCoef: 0,
weightedSum: 0,
};
}
const studentEval = studentEvaluations.find(
(se) => se.evaluation === ev.id
);
const evalData = {
...ev,
studentScore: studentEval?.score,
studentComment: studentEval?.comment,
isAbsent: studentEval?.is_absent,
};
acc[key].evaluations.push(evalData);
// Calcul de la moyenne pondérée
if (studentEval?.score != null && !studentEval?.is_absent) {
const normalizedScore = (studentEval.score / ev.max_score) * 20;
acc[key].weightedSum += normalizedScore * ev.coefficient;
acc[key].totalCoef += parseFloat(ev.coefficient);
acc[key].totalScore += studentEval.score;
acc[key].totalMaxScore += parseFloat(ev.max_score);
}
return acc;
}, {});
// Calcul de la moyenne générale
let totalWeightedSum = 0;
let totalCoef = 0;
Object.values(groupedBySpeciality).forEach((group) => {
if (group.totalCoef > 0) {
const groupAvg = group.weightedSum / group.totalCoef;
totalWeightedSum += groupAvg * group.totalCoef;
totalCoef += group.totalCoef;
}
});
const generalAverage = totalCoef > 0 ? totalWeightedSum / totalCoef : null;
const getScoreColor = (score, maxScore) => {
if (score == null) return 'text-gray-400';
const percentage = (score / maxScore) * 100;
if (percentage >= 70) return 'text-green-600';
if (percentage >= 50) return 'text-yellow-600';
return 'text-red-600';
};
const getAverageIcon = (avg) => {
if (avg >= 14) return <TrendingUp size={16} className="text-green-500" />;
if (avg >= 10) return <Minus size={16} className="text-yellow-500" />;
return <TrendingDown size={16} className="text-red-500" />;
};
return (
<div className="space-y-4">
{/* Moyenne générale */}
{generalAverage !== null && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<BookOpen className="text-emerald-600" size={24} />
<span className="font-medium text-emerald-800">Moyenne générale</span>
</div>
<div className="flex items-center gap-2">
{getAverageIcon(generalAverage)}
<span className="text-2xl font-bold text-emerald-700">
{generalAverage.toFixed(2)}/20
</span>
</div>
</div>
)}
{/* Évaluations par matière */}
{Object.values(groupedBySpeciality).map((group) => {
const groupAverage =
group.totalCoef > 0 ? group.weightedSum / group.totalCoef : null;
return (
<div
key={group.name}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Header de la matière */}
<div
className="p-3 flex items-center justify-between"
style={{ backgroundColor: `${group.color}15` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span className="font-semibold text-gray-800">{group.name}</span>
</div>
{groupAverage !== null && (
<div className="flex items-center gap-2">
{getAverageIcon(groupAverage)}
<span className="font-bold" style={{ color: group.color }}>
{groupAverage.toFixed(2)}/20
</span>
</div>
)}
</div>
{/* Liste des évaluations */}
<div className="divide-y divide-gray-100">
{group.evaluations.map((ev) => {
const studentEval = studentEvaluations.find(se => se.evaluation === ev.id);
const isEditing = editingId === ev.id;
return (
<div
key={ev.id}
className="p-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium text-gray-700">{ev.name}</div>
<div className="text-sm text-gray-500 flex gap-2">
{ev.date && (
<span>
{new Date(ev.date).toLocaleDateString('fr-FR')}
</span>
)}
<span>Coef: {ev.coefficient}</span>
</div>
{!isEditing && ev.studentComment && (
<div className="text-sm text-gray-500 italic mt-1">
&quot;{ev.studentComment}&quot;
</div>
)}
{isEditing && (
<input
type="text"
value={editComment}
onChange={(e) => setEditComment(e.target.value)}
placeholder="Commentaire"
className="mt-2 w-full text-sm px-2 py-1 border rounded"
/>
)}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<label className="flex items-center gap-1 text-sm text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked) setEditScore('');
}}
/>
Absent
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
min="0"
max={ev.max_score}
step="0.5"
className="w-16 text-center px-2 py-1 border rounded"
/>
)}
<span className="text-gray-500">/{ev.max_score}</span>
<button
onClick={() => handleSaveEdit(ev, studentEval)}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={16} />
</button>
<button
onClick={cancelEdit}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={16} />
</button>
</>
) : (
<>
{ev.isAbsent ? (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm font-medium">
Absent
</span>
) : ev.studentScore != null ? (
<span
className={`text-lg font-bold ${getScoreColor(
ev.studentScore,
ev.max_score
)}`}
>
{ev.studentScore}/{ev.max_score}
</span>
) : (
<span className="text-gray-400 text-sm">Non noté</span>
)}
{editable && studentEval && (
<>
<button
onClick={() => startEdit(ev, studentEval)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={16} />
</button>
{onDeleteGrade && (
<button
onClick={() => handleDelete(studentEval)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={16} />
</button>
)}
</>
)}
</>
)}
</div>
</div>
);})}
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { default as EvaluationForm } from './EvaluationForm';
export { default as EvaluationList } from './EvaluationList';
export { default as EvaluationGradeTable } from './EvaluationGradeTable';
export { default as EvaluationStudentView } from './EvaluationStudentView';

View File

@ -2,9 +2,16 @@
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
import {
CheckCircle,
Hourglass,
FileText,
Download,
Upload,
XCircle,
} from 'lucide-react';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
@ -36,8 +43,12 @@ export default function DynamicFormsList({
const dataState = { ...prevData };
schoolFileTemplates.forEach((tpl) => {
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
const hasLocalData =
prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalData && hasServerData) {
// Pas de données locales mais données serveur : utiliser les données serveur
@ -56,7 +67,10 @@ export default function DynamicFormsList({
const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => {
const hasLocalValidation = prevValidation[tpl.id] === true;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé
@ -76,13 +90,21 @@ export default function DynamicFormsList({
useEffect(() => {
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true ||
(tpl) =>
tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
onValidationChange(allFormsValid);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
}, [
formsData,
formsValidation,
existingResponses,
schoolFileTemplates,
onValidationChange,
]);
/**
* Gère la soumission d'un formulaire individuel
@ -177,7 +199,7 @@ export default function DynamicFormsList({
});
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
logger.error("Erreur lors de l'upload du fichier :", error);
}
};
@ -205,11 +227,15 @@ export default function DynamicFormsList({
<div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{
schoolFileTemplates.filter(tpl => {
schoolFileTemplates.filter((tpl) => {
// Validé ou complété localement
return tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
return (
tpl.isValidated === true ||
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
}).length
}
{' / '}
@ -219,11 +245,13 @@ export default function DynamicFormsList({
{/* Tri des templates par état */}
{(() => {
// Helper pour état
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
@ -234,11 +262,17 @@ export default function DynamicFormsList({
return (
<ul className="space-y-2">
{sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
const isActive =
schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated =
typeof tpl.isValidated === 'boolean'
? tpl.isValidated
: undefined;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
// Statut d'affichage
@ -258,8 +292,12 @@ export default function DynamicFormsList({
borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass;
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
borderClass = isActive
? 'border border-emerald-300'
: borderClass;
textClass = isActive
? 'text-emerald-900 font-semibold'
: textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
@ -267,16 +305,24 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'Refusé';
statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
borderClass = isActive
? 'border border-red-300'
: 'border border-red-200';
textClass = isActive
? 'text-red-900 font-semibold'
: 'text-red-700';
canEdit = true;
}
} else {
@ -285,8 +331,12 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'À compléter';
@ -294,7 +344,9 @@ export default function DynamicFormsList({
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : '';
borderClass = isActive ? 'border border-gray-300' : '';
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
textClass = isActive
? 'text-gray-900 font-semibold'
: 'text-gray-600';
canEdit = true;
}
}
@ -307,13 +359,22 @@ export default function DynamicFormsList({
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${bgClass} ${borderClass} ${textClass}`
}`}
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
onClick={() =>
setCurrentTemplateIndex(
schoolFileTemplates.findIndex((t) => t.id === tpl.id)
)
}
>
<span className="mr-3">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
{tpl.formMasterData?.title ||
tpl.title ||
tpl.name ||
'Formulaire sans nom'}
<span
className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}
>
{statusLabel}
</span>
</div>
@ -341,34 +402,52 @@ export default function DynamicFormsList({
</h3>
{/* Label d'état */}
{currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
Validé
</span>
) : (formsData[currentTemplate.id] &&
Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] &&
Object.keys(existingResponses[currentTemplate.id]).length >
0) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
Complété
</span>
) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
Refusé
</span>
)}
</div>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
currentTemplate.description || ''}
currentTemplate.description ||
''}
</p>
<div className="text-xs text-gray-500 mt-1">
Formulaire {(() => {
Formulaire{' '}
{(() => {
// Trouver l'index du template courant dans la liste triée
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
const sortedTemplates = [...schoolFileTemplates].sort(
(a, b) => getState(a) - getState(b)
);
const idx = sortedTemplates.findIndex(
(tpl) => tpl.id === currentTemplate.id
);
return idx + 1;
})()} sur {schoolFileTemplates.length}
})()}{' '}
sur {schoolFileTemplates.length}
</div>
</div>
@ -405,9 +484,10 @@ export default function DynamicFormsList({
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
{/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && (
{currentTemplate.isValidated === true &&
currentTemplate.file && (
<iframe
src={`${BASE_URL}${currentTemplate.file}`}
src={getSecureFileUrl(currentTemplate.file)}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
@ -420,9 +500,7 @@ export default function DynamicFormsList({
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
href={getSecureFileUrl(currentTemplate.file)}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
@ -436,7 +514,9 @@ export default function DynamicFormsList({
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
onFileSelect={(file) =>
handleUpload(file, currentTemplate)
}
required
enable={true}
/>

View File

@ -5,7 +5,7 @@ import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
const FilesModal = ({
@ -56,27 +56,27 @@ const FilesModal = ({
registrationFile: selectedRegisterForm.registration_file
? {
name: 'Fiche élève',
url: `${BASE_URL}${selectedRegisterForm.registration_file}`,
url: getSecureFileUrl(selectedRegisterForm.registration_file),
}
: null,
fusionFile: selectedRegisterForm.fusion_file
? {
name: 'Documents fusionnés',
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`,
url: getSecureFileUrl(selectedRegisterForm.fusion_file),
}
: null,
schoolFiles: fetchedSchoolFiles.map((file) => ({
name: file.name || 'Document scolaire',
url: file.file ? `${BASE_URL}${file.file}` : null,
url: file.file ? getSecureFileUrl(file.file) : null,
})),
parentFiles: parentFiles.map((file) => ({
name: file.master_name || 'Document parent',
url: file.file ? `${BASE_URL}${file.file}` : null,
url: file.file ? getSecureFileUrl(file.file) : null,
})),
sepaFile: selectedRegisterForm.sepa_file
? {
name: 'Mandat SEPA',
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`,
url: getSecureFileUrl(selectedRegisterForm.sepa_file),
}
: null,
};

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import Table from '@/components/Table';
import FileUpload from '@/components/Form/FileUpload';
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
@ -230,7 +230,7 @@ export default function FilesToUpload({
<div className="mt-4">
{actionType === 'view' && selectedFile.fileName ? (
<iframe
src={`${BASE_URL}${selectedFile.fileName}`}
src={getSecureFileUrl(selectedFile.fileName)}
title="Document Viewer"
className="w-full"
style={{

View File

@ -7,7 +7,7 @@ import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react';
import FileUpload from '@/components/Form/FileUpload';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { levels, genders } from '@/utils/constants';
export default function StudentInfoForm({
@ -57,7 +57,7 @@ export default function StudentInfoForm({
// Convertir la photo en fichier binaire si elle est un chemin ou une URL
if (photoPath && typeof photoPath === 'string') {
fetch(`${BASE_URL}${photoPath}`)
fetch(getSecureFileUrl(photoPath))
.then((response) => {
if (!response.ok) {
throw new Error('Erreur lors de la récupération de la photo.');

View File

@ -3,11 +3,11 @@ import React, { useState, useEffect } from 'react';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
@ -49,15 +49,18 @@ export default function ValidateSubscription({
// Parent templates
parentFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated
? 'accepted'
: 'refused';
}
});
setDocStatuses(s => ({ ...s, ...newStatuses }));
setDocStatuses((s) => ({ ...s, ...newStatuses }));
}, [schoolFileTemplates, parentFileTemplates]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
const [showFinalValidationPopup, setShowFinalValidationPopup] =
useState(false);
const [formData, setFormData] = useState({
associated_class: null,
@ -131,7 +134,7 @@ export default function ValidateSubscription({
const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés
let notes = 'Dossier non validé pour les raisons suivantes :\n';
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
notes += refusedDocs.map((doc) => `- ${doc.name}`).join('\n');
const data = {
status: 2,
notes,
@ -177,10 +180,18 @@ export default function ValidateSubscription({
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
const docIndexes = allTemplates
.map((_, idx) => idx)
.filter((idx) => idx !== 0);
const allChecked =
docIndexes.length > 0 &&
docIndexes.every(
(idx) => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused'
);
const allValidated =
docIndexes.length > 0 &&
docIndexes.every((idx) => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some((idx) => docStatuses[idx] === 'refused');
logger.debug(allTemplates);
return (
@ -202,7 +213,7 @@ export default function ValidateSubscription({
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
</h3>
<iframe
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`}
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
title={
allTemplates[currentTemplateIndex].type === 'main'
? 'Document Principal'
@ -252,18 +263,32 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
setDocStatuses((s) => ({
...s,
[index]: 'accepted',
}));
// Appel API pour valider le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
if (template && template.id) {
@ -284,18 +309,29 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
if (template && template.id) {
@ -351,7 +387,7 @@ export default function ValidateSubscription({
<div className="mt-auto py-4">
<Button
text="Soumettre"
onClick={e => {
onClick={(e) => {
e.preventDefault();
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
// 2. Si tous cochés et au moins un refusé : popup refus
@ -367,12 +403,14 @@ export default function ValidateSubscription({
}}
primary
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!allChecked || (allChecked && allValidated && !formData.associated_class)
!allChecked ||
(allChecked && allValidated && !formData.associated_class)
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
!allChecked ||
(allChecked && allValidated && !formData.associated_class)
}
/>
</div>
@ -391,7 +429,7 @@ export default function ValidateSubscription({
<span className="font-semibold text-blue-700">{email}</span>
{' avec la liste des documents non validés :'}
<ul className="list-disc ml-6 mt-2">
{refusedDocs.map(doc => (
{refusedDocs.map((doc) => (
<li key={doc.idx}>{doc.name}</li>
))}
</ul>

View File

@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights';
import { ChevronDown } from 'lucide-react'; // Import de l'icône
import Image from 'next/image'; // Import du composant Image
import {
BASE_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const {
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
selectedEstablishmentLogo,
setSelectedEstablishmentLogo
setSelectedEstablishmentLogo,
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
user.roles[roleId].establishment__evaluation_frequency;
const establishmentTotalCapacity =
user.roles[roleId].establishment__total_capacity;
const establishmentLogo =
user.roles[roleId].establishment__logo;
const establishmentLogo = user.roles[roleId].establishment__logo;
setProfileRole(role);
setSelectedEstablishmentId(establishmentId);
setSelectedEstablishmentEvaluationFrequency(
@ -108,7 +105,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md"
width={32}
@ -128,7 +129,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md"
width={64}
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
label: (
<div className="flex items-center text-left">
<Image
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)}
src={
establishment.logo
? getSecureFileUrl(establishment.logo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
width={32}
height={32}
/>
<div>
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div>
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div>
<div className="font-bold ext-sm text-gray-500">
{establishment.name}
</div>
<div className="italic text-sm text-gray-500">
{getRightStr(establishment.role_type)}
</div>
</div>
</div>
),
@ -212,7 +225,8 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
]
}
buttonClassName="w-full"
menuClassName={compact
menuClassName={
compact
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
}

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,
buttonOpeningModal = false,
onClick = null,
secondaryButton = null, // Bouton secondaire (ex: export)
}) => {
return (
<div className="flex items-center justify-between mb-6">
@ -29,6 +30,8 @@ const SectionHeader = ({
<p className="text-sm text-gray-500 italic">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{secondaryButton}
{button && onClick && (
<button
onClick={onClick}
@ -42,6 +45,7 @@ const SectionHeader = ({
</button>
)}
</div>
</div>
);
};

View File

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

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 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}

View File

@ -1,15 +1,17 @@
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 StructureManagement = ({
const StructureManagementContent = ({
specialities,
setSpecialities,
teachers,
@ -21,14 +23,29 @@ const StructureManagement = ({
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="w-full">
<ClassesProvider>
<>
<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={specialities}
specialities={filteredSpecialities}
allSpecialities={specialities}
setSpecialities={setSpecialities}
handleCreate={(newData) =>
handleCreate(
@ -52,9 +69,10 @@ const StructureManagement = ({
</div>
<div className="w-full xl:flex-1">
<TeachersSection
teachers={teachers}
teachers={filteredTeachers}
allTeachers={teachers}
setTeachers={setTeachers}
specialities={specialities}
specialities={filteredSpecialities}
profiles={profiles}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
@ -75,9 +93,10 @@ const StructureManagement = ({
</div>
<div className="w-full mt-8 xl:mt-12">
<ClassesSection
classes={classes}
classes={filteredClasses}
allClasses={classes}
setClasses={setClasses}
teachers={teachers}
teachers={filteredTeachers}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
@ -98,7 +117,40 @@ const StructureManagement = ({
}
/>
</div>
</>
);
};
const StructureManagement = ({
specialities,
setSpecialities,
teachers,
setTeachers,
classes,
setClasses,
profiles,
handleCreate,
handleEdit,
handleDelete,
}) => {
return (
<div className="w-full">
<SchoolYearFilterProvider>
<ClassesProvider>
<StructureManagementContent
specialities={specialities}
setSpecialities={setSpecialities}
teachers={teachers}
setTeachers={setTeachers}
classes={classes}
setClasses={setClasses}
profiles={profiles}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
</ClassesProvider>
</SchoolYearFilterProvider>
</div>
);
};

View File

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

View File

@ -1,12 +1,5 @@
import React, { useState, useEffect } from 'react';
import {
Edit,
Trash2,
FileText,
Star,
ChevronDown,
Plus,
} from 'lucide-react';
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
import Modal from '@/components/Modal';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import {
@ -38,7 +31,7 @@ import DropdownMenu from '@/components/DropdownMenu';
import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
function getItemBgColor(type, selected, forceTheme = false) {
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
@ -73,7 +66,9 @@ function SimpleList({
groupDocCount = null,
}) {
return (
<div className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}>
<div
className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}
>
{title && (
<div
className={`
@ -85,7 +80,9 @@ function SimpleList({
${headerClassName}
`}
>
{headerContent ? headerContent : (
{headerContent ? (
headerContent
) : (
<span className="text-base text-gray-700">{title}</span>
)}
</div>
@ -106,11 +103,12 @@ function SimpleList({
? 'z-0 relative'
: '';
const marginFix =
selectable && idx !== items.length - 1
? '-mb-[1px]'
: '';
selectable && idx !== items.length - 1 ? '-mb-[1px]' : '';
let description = '';
if (typeof item.description === 'string' && item.description.trim()) {
if (
typeof item.description === 'string' &&
item.description.trim()
) {
description = item.description;
} else if (
item._type === 'emerald' &&
@ -124,17 +122,17 @@ function SimpleList({
}
const groupsLabel =
showGroups && Array.isArray(item.groups) && item.groups.length > 0
? item.groups.map(g => g.name).join(', ')
? item.groups.map((g) => g.name).join(', ')
: null;
const docCount = groupDocCount && typeof groupDocCount === 'function'
const docCount =
groupDocCount && typeof groupDocCount === 'function'
? groupDocCount(item)
: null;
const showCustomForm =
item._type === 'emerald' &&
Array.isArray(item.formMasterData?.fields) &&
item.formMasterData.fields.length > 0;
const showRequired =
item._type === 'orange' && item.is_required;
const showRequired = item._type === 'orange' && item.is_required;
// Correction du bug liseré : appliquer un z-index élevé au premier item sélectionné
const extraZ = selected && idx === 0 ? 'z-20 relative' : '';
@ -163,7 +161,9 @@ function SimpleList({
</div>
<div className="flex items-center gap-2">
{docCount !== null && (
<span className="text-xs text-blue-700 font-semibold mr-2">{docCount} document{docCount > 1 ? 's' : ''}</span>
<span className="text-xs text-blue-700 font-semibold mr-2">
{docCount} document{docCount > 1 ? 's' : ''}
</span>
)}
{showCustomForm && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border border-yellow-600 bg-yellow-400 text-yellow-900 mr-1">
@ -176,7 +176,9 @@ function SimpleList({
</span>
)}
{showGroups && groupsLabel && (
<span className="text-xs text-gray-500 mr-2">{groupsLabel}</span>
<span className="text-xs text-gray-500 mr-2">
{groupsLabel}
</span>
)}
{actionButtons && actionButtons(item)}
</div>
@ -192,7 +194,7 @@ function SimpleList({
export default function FilesGroupsManagement({
csrfToken,
selectedEstablishmentId,
profileRole
profileRole,
}) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]);
@ -246,7 +248,12 @@ export default function FilesGroupsManagement({
return found || group;
} else {
// C'est un ID
return groups.find((g) => g.id === group) || { id: group, name: 'Groupe inconnu' };
return (
groups.find((g) => g.id === group) || {
id: group,
name: 'Groupe inconnu',
}
);
}
});
return {
@ -323,7 +330,11 @@ export default function FilesGroupsManagement({
const editTemplateMaster = (file) => {
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
if (!file.formMasterData || !Array.isArray(file.formMasterData.fields) || file.formMasterData.fields.length === 0) {
if (
!file.formMasterData ||
!Array.isArray(file.formMasterData.fields) ||
file.formMasterData.fields.length === 0
) {
setFileToEdit(file);
setIsFileUploadPopupOpen(true);
setIsEditing(true);
@ -334,7 +345,12 @@ export default function FilesGroupsManagement({
}
};
const handleCreateSchoolFileMaster = ({ name, group_ids, formMasterData, file }) => {
const handleCreateSchoolFileMaster = ({
name,
group_ids,
formMasterData,
file,
}) => {
// Toujours envoyer en FormData, même sans fichier
const dataToSend = new FormData();
const jsonData = {
@ -390,7 +406,7 @@ export default function FilesGroupsManagement({
// Correction : normaliser group_ids pour ne garder que les IDs (number/string)
let normalizedGroupIds = [];
if (Array.isArray(group_ids)) {
normalizedGroupIds = group_ids.map(g =>
normalizedGroupIds = group_ids.map((g) =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
);
}
@ -400,7 +416,7 @@ export default function FilesGroupsManagement({
name: name,
groups: normalizedGroupIds,
formMasterData: formMasterData,
establishment: selectedEstablishmentId
establishment: selectedEstablishmentId,
};
dataToSend.append('data', JSON.stringify(jsonData));
@ -432,12 +448,12 @@ export default function FilesGroupsManagement({
const finalFileName = `${cleanName}${extension}`;
// Correction : il faut récupérer le fichier à l'URL d'origine, pas à la nouvelle URL renommée
// On utilise le path original (file) pour le fetch, pas le chemin avec le nouveau nom
fetch(`${BASE_URL}${file}`)
.then(response => {
fetch(getSecureFileUrl(file))
.then((response) => {
if (!response.ok) throw new Error('Fichier distant introuvable');
return response.blob();
})
.then(blob => {
.then((blob) => {
dataToSend.append('file', blob, finalFileName);
editRegistrationSchoolFileMaster(id, dataToSend, csrfToken)
.then((data) => {
@ -461,7 +477,10 @@ export default function FilesGroupsManagement({
});
})
.catch((error) => {
logger.error('Erreur lors de la récupération du fichier existant pour renommage:', error);
logger.error(
'Erreur lors de la récupération du fichier existant pour renommage:',
error
);
showNotification(
'Erreur lors de la récupération du fichier existant pour renommage',
'error',
@ -620,15 +639,28 @@ export default function FilesGroupsManagement({
// Correction du bug : ne pas supprimer l'élément lors de l'édition d'un doc parent
const handleEdit = (id, updatedFile) => {
logger.debug('[FilesGroupsManagement] handleEdit called with:', id, updatedFile);
logger.debug(
'[FilesGroupsManagement] handleEdit called with:',
id,
updatedFile
);
if (typeof updatedFile !== 'object' || updatedFile === null) {
logger.error('[FilesGroupsManagement] handleEdit: updatedFile is not an object', updatedFile);
logger.error(
'[FilesGroupsManagement] handleEdit: updatedFile is not an object',
updatedFile
);
return Promise.reject(new Error('updatedFile is not an object'));
}
logger.debug('[FilesGroupsManagement] handleEdit payload:', JSON.stringify(updatedFile));
logger.debug(
'[FilesGroupsManagement] handleEdit payload:',
JSON.stringify(updatedFile)
);
return editRegistrationParentFileMaster(id, updatedFile, csrfToken)
.then((response) => {
logger.debug('[FilesGroupsManagement] editRegistrationParentFileMaster response:', response);
logger.debug(
'[FilesGroupsManagement] editRegistrationParentFileMaster response:',
response
);
const modifiedFile = response.data || response;
setParentFileMasters((prevFiles) =>
prevFiles.map((file) => (file.id === id ? modifiedFile : file))
@ -654,19 +686,32 @@ export default function FilesGroupsManagement({
const handleDelete = (id) => {
// Vérification avant suppression : afficher une popup de confirmation
setRemovePopupMessage(
'Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l\'opération ?'
"Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
);
setRemovePopupOnConfirm(() => () => {
deleteRegistrationParentFileMaster(id, csrfToken)
.then(() => {
setParentFileMasters((prevFiles) => prevFiles.filter((file) => file.id !== id));
setParentFileMasters((prevFiles) =>
prevFiles.filter((file) => file.id !== id)
);
logger.debug('Document parent supprimé avec succès:', id);
showNotification('La pièce à fournir a été supprimée avec succès.', 'success', 'Succès');
showNotification(
'La pièce à fournir a été supprimée avec succès.',
'success',
'Succès'
);
setRemovePopupVisible(false);
})
.catch((error) => {
logger.error('Erreur lors de la suppression du fichier parent:', error);
showNotification('Erreur lors de la suppression de la pièce à fournir.', 'error', 'Erreur');
logger.error(
'Erreur lors de la suppression du fichier parent:',
error
);
showNotification(
'Erreur lors de la suppression de la pièce à fournir.',
'error',
'Erreur'
);
setRemovePopupVisible(false);
});
});
@ -701,13 +746,28 @@ export default function FilesGroupsManagement({
aria-expanded={showHelp}
aria-controls="aide-inscription"
>
<span className="underline">{showHelp ? 'Masquer' : 'Afficher'} laide</span>
<svg className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
<span className="underline">
{showHelp ? 'Masquer' : 'Afficher'} laide
</span>
<svg
className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showHelp && (
<div id="aide-inscription" className="p-4 bg-blue-50 border border-blue-200 rounded mb-4">
<div
id="aide-inscription"
className="p-4 bg-blue-50 border border-blue-200 rounded mb-4"
>
<h2 className="text-lg font-bold mb-2">
Gestion des dossiers et documents d&apos;inscription
</h2>
@ -715,33 +775,61 @@ export default function FilesGroupsManagement({
<p>
<span className="font-semibold">Organisation de la page :</span>
<br />
<span className="text-blue-700 font-semibold">Colonne de gauche</span> : liste des dossiers d&apos;inscription (groupes/classes).
<span className="text-blue-700 font-semibold">
Colonne de gauche
</span>{' '}
: liste des dossiers d&apos;inscription (groupes/classes).
<br />
<span className="text-emerald-700 font-semibold">Colonne de droite</span> : liste des documents à fournir pour l&apos;inscription.
<span className="text-emerald-700 font-semibold">
Colonne de droite
</span>{' '}
: liste des documents à fournir pour l&apos;inscription.
</p>
<p>
<span className="font-semibold">Ajout de dossiers :</span>
<br />
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste pour créer un nouveau dossier d&apos;inscription.
Cliquez sur le bouton{' '}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
+
</span>{' '}
à droite de la liste pour créer un nouveau dossier
d&apos;inscription.
</p>
<p>
<span className="font-semibold">Ajout de documents :</span>
<br />
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste des documents pour ajouter :
Cliquez sur le bouton{' '}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
+
</span>{' '}
à droite de la liste des documents pour ajouter :
</p>
<ul className="list-disc list-inside ml-6">
<li>
<span className="text-yellow-700 font-semibold">Formulaire personnalisé</span> : créé dynamiquement par l&apos;école, à remplir et/ou signer électroniquement par la famille.
<span className="text-yellow-700 font-semibold">
Formulaire personnalisé
</span>{' '}
: créé dynamiquement par l&apos;école, à remplir et/ou signer
électroniquement par la famille.
</li>
<li>
<span className="text-black font-semibold">Formulaire existant</span> : importez un PDF ou autre document à faire remplir.
<span className="text-black font-semibold">
Formulaire existant
</span>{' '}
: importez un PDF ou autre document à faire remplir.
</li>
<li>
<span className="text-orange-700 font-semibold">Pièce à fournir</span> : document à déposer par la famille (ex : RIB, justificatif de domicile).
<span className="text-orange-700 font-semibold">
Pièce à fournir
</span>{' '}
: document à déposer par la famille (ex : RIB, justificatif de
domicile).
</li>
</ul>
<div className="mt-2 text-sm text-gray-600">
<span className="font-semibold">Astuce :</span> Créez d&apos;abord vos dossiers d&apos;inscription avant d&apos;ajouter des documents à fournir.
<span className="font-semibold">Astuce :</span> Créez d&apos;abord
vos dossiers d&apos;inscription avant d&apos;ajouter des documents
à fournir.
</div>
</div>
</div>
@ -764,14 +852,13 @@ export default function FilesGroupsManagement({
filteredParentFiles = parentFiles.filter(
(file) =>
file.groups &&
file.groups.some((gid) =>
(typeof gid === 'object' ? gid.id : gid) === selectedGroupId
file.groups.some(
(gid) => (typeof gid === 'object' ? gid.id : gid) === selectedGroupId
)
);
}
const mergedDocuments =
selectedGroupId
const mergedDocuments = selectedGroupId
? [
...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })),
...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })),
@ -783,17 +870,19 @@ export default function FilesGroupsManagement({
const groupId = group.id;
let count = 0;
// Documents école
count += schoolFileMasters.filter(
(file) =>
count += schoolFileMasters.filter((file) =>
Array.isArray(file.groups)
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
? file.groups.some(
(g) => (typeof g === 'object' ? g.id : g) === groupId
)
: false
).length;
// Pièces à fournir
count += parentFiles.filter(
(file) =>
count += parentFiles.filter((file) =>
Array.isArray(file.groups)
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
? file.groups.some(
(g) => (typeof g === 'object' ? g.id : g) === groupId
)
: false
).length;
return count;
@ -840,7 +929,10 @@ export default function FilesGroupsManagement({
actionButtons={(row) => (
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleGroupEdit(row); }}
onClick={(e) => {
e.stopPropagation();
handleGroupEdit(row);
}}
className="p-2 rounded-full hover:bg-gray-100 transition"
title="Modifier"
>
@ -849,7 +941,10 @@ export default function FilesGroupsManagement({
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleGroupDelete(row.id); }}
onClick={(e) => {
e.stopPropagation();
handleGroupDelete(row.id);
}}
className="p-2 rounded-full hover:bg-gray-100 transition"
title="Supprimer"
>
@ -894,7 +989,8 @@ export default function FilesGroupsManagement({
Formulaire existant
</span>
),
onClick: () => handleDocDropdownSelect('formulaire_existant'),
onClick: () =>
handleDocDropdownSelect('formulaire_existant'),
},
{
type: 'item',
@ -1008,12 +1104,18 @@ export default function FilesGroupsManagement({
setIsEditing(false);
}
}}
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire personnalisé'}
title={
isEditing
? 'Modification du formulaire'
: 'Créer un formulaire personnalisé'
}
>
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
<FormTemplateBuilder
onSave={(data) => {
(isEditing ? handleEditSchoolFileMaster : handleCreateSchoolFileMaster)(data);
(isEditing
? handleEditSchoolFileMaster
: handleCreateSchoolFileMaster)(data);
setIsModalOpen(false);
}}
initialData={isEditing ? fileToEdit : undefined}
@ -1027,15 +1129,25 @@ export default function FilesGroupsManagement({
<Modal
isOpen={isFileUploadPopupOpen}
setIsOpen={setIsFileUploadPopupOpen}
title={fileToEdit && fileToEdit.id ? 'Modifier le document existant' : 'Télécharger un document existant'}
title={
fileToEdit && fileToEdit.id
? 'Modifier le document existant'
: 'Télécharger un document existant'
}
>
<div className="w-full max-h-[90vh] overflow-y-auto">
{fileToEdit && fileToEdit.id ? (
<form
className="flex flex-col gap-4 w-full"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
if (
!fileToEdit?.name ||
!fileToEdit?.groups ||
fileToEdit.groups.length === 0 ||
!fileToEdit?.file
)
return;
if (isEditing) {
handleEditSchoolFileMaster({
id: fileToEdit.id,
@ -1059,30 +1171,38 @@ export default function FilesGroupsManagement({
label="Nom du document"
name="name"
value={fileToEdit?.name || ''}
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
onChange={(e) =>
setFileToEdit({ ...fileToEdit, name: e.target.value })
}
required
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (fileToEdit?.groups || []).map(
(g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
@ -1104,14 +1224,16 @@ export default function FilesGroupsManagement({
{fileToEdit?.file && (
<div className="flex items-center gap-2 mb-2">
<FileText className="w-5 h-5 text-gray-600" />
<span className="text-sm truncate">{fileToEdit.file.name || fileToEdit.file.path || 'Document sélectionné'}</span>
<span className="text-sm truncate">
{fileToEdit.file.name ||
fileToEdit.file.path ||
'Document sélectionné'}
</span>
</div>
)}
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
onFileSelect={file =>
setFileToEdit({ ...fileToEdit, file })
}
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
required
enable
/>
@ -1131,9 +1253,15 @@ export default function FilesGroupsManagement({
) : (
<form
className="flex flex-col gap-4 w-full"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
if (
!fileToEdit?.name ||
!fileToEdit?.groups ||
fileToEdit.groups.length === 0 ||
!fileToEdit?.file
)
return;
handleCreateSchoolFileMaster({
name: fileToEdit.name,
group_ids: fileToEdit.groups,
@ -1147,30 +1275,38 @@ export default function FilesGroupsManagement({
label="Nom du document"
name="name"
value={fileToEdit?.name || ''}
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
onChange={(e) =>
setFileToEdit({ ...fileToEdit, name: e.target.value })
}
required
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (fileToEdit?.groups || []).map(
(g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
@ -1190,9 +1326,7 @@ export default function FilesGroupsManagement({
</div>
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
onFileSelect={file =>
setFileToEdit({ ...fileToEdit, file })
}
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
required
enable
/>
@ -1229,13 +1363,14 @@ export default function FilesGroupsManagement({
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto">
<form
className="flex flex-col gap-4"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (
!editingParentFile?.name ||
!editingParentFile?.groups ||
editingParentFile.groups.length === 0
) return;
)
return;
const payload = {
name: editingParentFile.name,
description: editingParentFile.description || '',
@ -1255,41 +1390,61 @@ export default function FilesGroupsManagement({
label="Nom de la pièce à fournir"
name="name"
value={editingParentFile?.name || ''}
onChange={e => setEditingParentFile({ ...editingParentFile, name: e.target.value })}
onChange={(e) =>
setEditingParentFile({
...editingParentFile,
name: e.target.value,
})
}
required
/>
<InputText
label="Description"
name="description"
value={editingParentFile?.description || ''}
onChange={e => setEditingParentFile({ ...editingParentFile, description: e.target.value })}
onChange={(e) =>
setEditingParentFile({
...editingParentFile,
description: e.target.value,
})
}
required={false}
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (editingParentFile?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (
editingParentFile?.groups || []
).map((g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
setEditingParentFile({ ...editingParentFile, groups: group_ids });
setEditingParentFile({
...editingParentFile,
groups: group_ids,
});
}}
fieldName="groups"
itemLabelFunc={() => group.name}

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

@ -0,0 +1,54 @@
import { getToken } from 'next-auth/jwt';
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const token = await getToken({
req,
secret: process.env.AUTH_SECRET,
cookieName: 'n3wtschool_session_token',
});
if (!token?.token) {
return res.status(401).json({ error: 'Non authentifié' });
}
const { path } = req.query;
if (!path) {
return res.status(400).json({ error: 'Le paramètre "path" est requis' });
}
try {
const backendUrl = `${BACKEND_URL}/Common/serve-file/?path=${encodeURIComponent(path)}`;
const backendRes = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token.token}`,
Connection: 'close',
},
});
if (!backendRes.ok) {
return res.status(backendRes.status).json({
error: `Erreur backend: ${backendRes.status}`,
});
}
const contentType =
backendRes.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = backendRes.headers.get('content-disposition');
res.setHeader('Content-Type', contentType);
if (contentDisposition) {
res.setHeader('Content-Disposition', contentDisposition);
}
const buffer = Buffer.from(await backendRes.arrayBuffer());
return res.send(buffer);
} catch {
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
}
}

View File

@ -40,6 +40,9 @@ export const BE_SCHOOL_DISCOUNTS_URL = `${BASE_URL}/School/discounts`;
export const BE_SCHOOL_PAYMENT_PLANS_URL = `${BASE_URL}/School/paymentPlans`;
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`;

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;

View File

@ -0,0 +1,25 @@
/**
* Construit l'URL sécurisée pour accéder à un fichier media via le proxy Next.js.
* Le proxy `/api/download` injecte le JWT côté serveur avant de transmettre au backend Django.
*
* Gère les chemins relatifs ("/data/some/file.pdf") et les URLs absolues du backend
* ("http://backend:8000/data/some/file.pdf").
*
* @param {string} filePath - Chemin ou URL complète du fichier
* @returns {string|null} URL vers /api/download?path=... ou null si pas de chemin
*/
export const getSecureFileUrl = (filePath) => {
if (!filePath) return null;
// Si c'est une URL absolue, extraire le chemin /data/...
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const url = new URL(filePath);
filePath = url.pathname;
} catch {
return null;
}
}
return `/api/download?path=${encodeURIComponent(filePath)}`;
};

View File

@ -4,7 +4,25 @@ module.exports = {
'./src/**/*.{js,jsx,ts,tsx}', // Ajustez ce chemin selon la structure de votre projet
],
theme: {
extend: {},
extend: {
colors: {
primary: '#059669',
secondary: '#064E3B',
tertiary: '#10B981',
neutral: '#F8FAFC',
},
fontFamily: {
headline: ['var(--font-manrope)', 'Manrope', 'sans-serif'],
body: ['var(--font-inter)', 'Inter', 'sans-serif'],
label: ['var(--font-inter)', 'Inter', 'sans-serif'],
},
borderRadius: {
DEFAULT: '4px',
sm: '2px',
md: '6px',
lg: '8px',
},
},
},
plugins: [require('@tailwindcss/forms')],
};

293
docs/design-system.md Normal file
View File

@ -0,0 +1,293 @@
# Design System — N3WT-SCHOOL
## Vue d'ensemble
Le design system N3WT-SCHOOL définit les tokens visuels et les conventions d'usage pour garantir une interface cohérente sur l'ensemble du produit.
---
## Couleurs
Les couleurs sont définies comme tokens Tailwind dans `Front-End/tailwind.config.js`.
| Token Tailwind | Valeur hex | Usage |
| -------------- | ---------- | -------------------------------------------------- |
| `primary` | `#059669` | Boutons principaux, CTA, éléments interactifs clés |
| `secondary` | `#064E3B` | Hover des éléments primaires, accents sombres |
| `tertiary` | `#10B981` | Badges, icônes d'accent, highlights |
| `neutral` | `#F8FAFC` | Fonds de page, surfaces, arrière-plans neutres |
### Règles d'usage
- **Ne jamais** utiliser les classes Tailwind brutes `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens (`primary`, `secondary`, `tertiary`).
- Les fonds décoratifs (gradients, illustrations) peuvent conserver les classes Tailwind standards.
- Les états de statut (success, error, warning, info) peuvent conserver leurs couleurs sémantiques (`green`, `red`, `yellow`, `blue`).
### Exemples
```jsx
// Bouton principal
<button className="bg-primary hover:bg-secondary text-white">Valider</button>
// Texte accent
<span className="text-primary font-medium">Voir plus</span>
// Badge
<span className="bg-tertiary/10 text-tertiary">Actif</span>
// Fond de page
<div className="bg-neutral min-h-screen">...</div>
```
---
## Typographie
Les polices sont chargées via `next/font/google` dans `Front-End/src/app/layout.js` et exposées comme variables CSS.
| Rôle | Police | Token Tailwind | Usage |
| --------- | --------- | --------------- | ----------------------------------- |
| Headlines | `Manrope` | `font-headline` | `h1`, `h2`, `h3`, titres de section |
| Body | `Inter` | `font-body` | Paragraphes, contenu (défaut) |
| Labels | `Inter` | `font-label` | Boutons, labels de formulaires |
> `font-body` est appliqué par défaut sur `<body>`. Il n'est donc pas nécessaire de l'ajouter manuellement sur chaque élément de texte courant.
### Exemples
```jsx
// Titre de page
<h1 className="font-headline text-2xl font-bold text-gray-900">Tableau de bord</h1>
// Sous-titre
<h2 className="font-headline text-xl font-semibold text-secondary">Élèves</h2>
// Label de formulaire
<label className="font-label text-sm font-medium text-gray-700">Prénom</label>
```
---
## Rayon de bordure (Border Radius)
Arrondi subtil, niveau 1.
| Token Tailwind | Valeur | Usage |
| -------------- | ------ | ---------------------------------- |
| `rounded-sm` | `2px` | Éléments très petits (badges fins) |
| `rounded` | `4px` | Par défaut — boutons, inputs |
| `rounded-md` | `6px` | Cards, modales |
| `rounded-lg` | `8px` | Grandes surfaces |
> Éviter `rounded-xl` (12px) et `rounded-full` sauf pour les avatars ou indicateurs circulaires.
---
## Espacement
Espacement normal, base 8px (niveau 2). Utiliser les multiples de 4px/8px du système Tailwind standard.
| Classe | Valeur |
| ------ | ------ |
| `p-1` | 4px |
| `p-2` | 8px |
| `p-3` | 12px |
| `p-4` | 16px |
| `p-6` | 24px |
| `p-8` | 32px |
---
## Composants récurrents
### Bouton principal
```jsx
<button className="bg-primary hover:bg-secondary text-white font-label font-medium px-4 py-2 rounded transition-colors">
Action
</button>
```
### Bouton secondaire
```jsx
<button className="border border-primary text-primary hover:bg-primary hover:text-white font-label font-medium px-4 py-2 rounded transition-colors">
Action secondaire
</button>
```
### Card
```jsx
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-4">
<h2 className="font-headline text-lg font-semibold text-gray-900">Titre</h2>
<p className="text-gray-600 mt-2">Contenu</p>
</div>
```
### Badge de statut
```jsx
<span className="inline-flex items-center bg-tertiary/10 text-tertiary text-xs font-label font-medium px-2 py-0.5 rounded">
Actif
</span>
```
### Lien d'action
```jsx
<a className="text-primary hover:text-secondary underline-offset-2 hover:underline transition-colors">
Voir le détail
</a>
```
---
## Configuration Tailwind
Fichier : `Front-End/tailwind.config.js`
```js
theme: {
extend: {
colors: {
primary: '#059669',
secondary: '#064E3B',
tertiary: '#10B981',
neutral: '#F8FAFC',
},
fontFamily: {
headline: ['var(--font-manrope)', 'Manrope', 'sans-serif'],
body: ['var(--font-inter)', 'Inter', 'sans-serif'],
label: ['var(--font-inter)', 'Inter', 'sans-serif'],
},
borderRadius: {
DEFAULT: '4px',
sm: '2px',
md: '6px',
lg: '8px',
},
},
},
```
---
## Icônes
Toutes les icônes utilisent la bibliothèque **`lucide-react`** exclusivement.
```jsx
import { Home, User, ChevronRight } from 'lucide-react';
// Taille standard
<Home size={20} className="text-primary" />
// Avec label accessible
<button className="flex items-center gap-2">
<Plus size={16} />
Ajouter
</button>
```
### Règles icônes
- **Toujours** importer depuis `lucide-react` — jamais d'autres bibliothèques d'icônes.
- Taille par défaut : `size={20}` (inline), `size={24}` (boutons standalone).
- Couleur : via `className="text-*"` — ne jamais utiliser le prop `color`.
- Icônes seules sans texte : ajouter `aria-label` ou wrapper `title` pour l'accessibilité.
---
## Responsive & PWA
L'interface est conçue **mobile-first** pour un usage PWA (Progressive Web App).
### Breakpoints Tailwind
| Préfixe | Largeur min | Contexte |
| -------- | ----------- | ------------------------ |
| _(base)_ | 0px | Mobile (priorité) |
| `sm:` | 640px | Grands mobiles/tablettes |
| `md:` | 768px | Tablettes |
| `lg:` | 1024px | Desktop |
### Principes responsive
- **Toujours** penser desktop en premier — les styles de base ciblent le desktop.
- Les sidebars passent en overlay sur mobile (`md:hidden` / `hidden md:block`).
- Les tableaux utilisent le mode `responsive-table` (classe utilitaire définie dans `tailwind.css`) sur mobile.
- Les grilles : commencer par `grid-cols-1`, étendre avec `sm:grid-cols-2 lg:grid-cols-3`.
- Les textes : tailles mobiles d'abord (`text-sm sm:text-base`), ne jamais forcer une grande taille sur mobile.
- Touch targets : minimum `44px × 44px` pour tous les éléments interactifs (`min-h-[44px]`).
### PWA
- Tous les écrans doivent fonctionner hors-ligne ou en mode dégradé (données en cache).
- Les interactions clés (navigation, actions principales) doivent être accessibles sans rechargement de page.
- Pas de contenu critique uniquement visible au survol (`hover:`) — prévoir une alternative tactile.
### Exemples responsive
```jsx
// Layout page
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
// Grille de cards
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
// Titre responsive
<h1 className="font-headline text-xl sm:text-2xl lg:text-3xl font-bold">
// Bouton full-width sur mobile
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
// Navigation mobile/desktop
<nav className="hidden md:flex gap-4">
<nav className="flex md:hidden"> {/* version mobile */}
```
---
## Bibliothèque de composants existants
**Avant de créer un nouveau composant, toujours vérifier s'il en existe un dans `Front-End/src/components/`.**
### Composants disponibles (inventaire)
| Composant | Chemin | Usage |
| --------------- | ----------------------------- | ----------------------------------------- |
| `AlertMessage` | `components/AlertMessage.js` | Messages success / error / warning / info |
| `Modal` | `components/Modal.js` | Fenêtre modale générique |
| `Pagination` | `components/Pagination.js` | Pagination de listes |
| `SectionHeader` | `components/SectionHeader.js` | En-tête de section avec icône |
| `ProgressStep` | `components/ProgressStep.js` | Étapes d'un formulaire multi-step |
| `EventCard` | `components/EventCard.js` | Card d'événement de planning |
| `Calendar/*` | `components/Calendar/` | Vues calendrier (semaine, mois…) |
| `Chat/*` | `components/Chat/` | Interface de messagerie |
| `Evaluation/*` | `components/Evaluation/` | Formulaires d'évaluation |
| `Grades/*` | `components/Grades/` | Affichage et saisie des notes |
| `Form/*` | `components/Form/` | Inputs, selects, composants de formulaire |
| `Admin/*` | `components/Admin/` | Composants spécifiques à l'admin |
| `Charts/*` | `components/Charts/` | Graphiques et visualisations |
### Règles de réutilisation
1. **Chercher avant de créer** : effectuer une recherche dans `components/` avant d'implémenter un nouveau composant.
2. **Étendre, ne pas dupliquer** : si un composant existant est proche mais manque d'une variante, l'étendre via des props — ne pas créer une copie.
3. **Props plutôt que fork** : passer des variantes via `variant`, `size`, `className` plutôt que de dupliquer le JSX.
4. **Appliquer le design system dans les composants** : tout composant existant ou nouveau doit utiliser les tokens (`primary`, `secondary`, `tertiary`, `neutral`, `font-headline`, etc.) — jamais de couleurs codées en dur.
---
## Règles pour les agents IA (Copilot / Claude)
1. **Couleurs interactives** : toujours utiliser `primary`, `secondary`, `tertiary`, `neutral` — jamais `emerald-*` pour les boutons, liens, icônes actives.
2. **Typographie** : utiliser `font-headline` sur les `h1`/`h2`/`h3`. Le `font-body` est le défaut.
3. **Arrondi** : préférer `rounded` (4px) pour les éléments courants. Éviter `rounded-xl` sauf exception justifiée.
4. **Espacement** : respecter la grille 4px/8px. Pas de valeurs arbitraires `p-[13px]`.
5. **Mode** : interface en mode **light** uniquement — ne pas ajouter de support dark mode.
6. **Icônes** : utiliser uniquement `lucide-react` — jamais d'autres bibliothèques d'icônes.
7. **Responsive** : mobile-first — les styles de base ciblent le mobile, les breakpoints `sm:`/`md:`/`lg:` étendent vers le haut.
8. **PWA** : pas d'interactions uniquement au survol, touch targets ≥ 44px, navigation sans rechargement.
9. **Réutilisation** : chercher un composant existant dans `components/` avant d'en créer un nouveau.

View File

@ -5,7 +5,8 @@
"prepare": "husky",
"release": "standard-version",
"update-version": "node scripts/update-version.js",
"format": "prettier --write Front-End"
"format": "prettier --write Front-End",
"create-establishment": "node scripts/create-establishment.js"
},
"standard-version": {
"scripts": {

View File

@ -0,0 +1,437 @@
#!/usr/bin/env node
/**
* CLI pour créer un ou plusieurs établissements N3WT.
*
* Usage interactif :
* node scripts/create-establishment.js
*
* Usage batch (fichier JSON) :
* node scripts/create-establishment.js --file batch.json
*
* Format du fichier batch :
* {
* "backendUrl": "http://localhost:8080", // optionnel (fallback : URL_DJANGO dans conf/backend.env)
* "apiKey": "TOk3n1234!!", // optionnel (fallback : WEBHOOK_API_KEY dans conf/backend.env)
* "establishments": [
* {
* "name": "École Dupont",
* "address": "1 rue de la Paix, Paris",
* "total_capacity": 300,
* "establishment_type": [1, 2], // 1=Maternelle, 2=Primaire, 3=Secondaire
* "evaluation_frequency": 1, // 1=Trimestre, 2=Semestre, 3=Année
* "licence_code": "LIC001", // optionnel
* "directeur": {
* "email": "directeur@dupont.fr",
* "password": "motdepasse123",
* "last_name": "Dupont",
* "first_name": "Jean"
* }
* }
* ]
* }
*/
const readline = require("readline");
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
// ── Lecture de conf/backend.env ───────────────────────────────────────────────
function loadBackendEnv() {
const envPath = path.join(__dirname, "..", "conf", "backend.env");
if (!fs.existsSync(envPath)) return;
const lines = fs.readFileSync(envPath, "utf8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Ne pas écraser les variables déjà définies dans l'environnement
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
// ── Helpers readline ──────────────────────────────────────────────────────────
let rl;
function getRL() {
if (!rl) {
rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
return rl;
}
function ask(question, defaultValue) {
const suffix =
defaultValue != null && defaultValue !== "" ? ` (${defaultValue})` : "";
return new Promise((resolve) => {
getRL().question(`${question}${suffix}: `, (answer) => {
resolve(
answer.trim() || (defaultValue != null ? String(defaultValue) : ""),
);
});
});
}
function askRequired(question) {
return new Promise((resolve) => {
const prompt = () => {
getRL().question(`${question}: `, (answer) => {
if (!answer.trim()) {
console.log(" ⚠ Ce champ est obligatoire.");
prompt();
} else {
resolve(answer.trim());
}
});
};
prompt();
});
}
function askChoices(question, choices) {
return new Promise((resolve) => {
console.log(`\n${question}`);
choices.forEach((c, i) => console.log(` ${i + 1}. ${c.label}`));
const prompt = () => {
getRL().question(
"Choix (numéros séparés par des virgules): ",
(answer) => {
const nums = answer
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => n >= 1 && n <= choices.length);
if (nums.length === 0) {
console.log(" ⚠ Sélectionnez au moins une option.");
prompt();
} else {
resolve(nums.map((n) => choices[n - 1].value));
}
},
);
};
prompt();
});
}
function askChoice(question, choices, defaultIndex) {
return new Promise((resolve) => {
console.log(`\n${question}`);
choices.forEach((c, i) =>
console.log(
` ${i + 1}. ${c.label}${i === defaultIndex ? " (défaut)" : ""}`,
),
);
const prompt = () => {
getRL().question(`Choix (1-${choices.length}): `, (answer) => {
if (!answer.trim() && defaultIndex != null) {
resolve(choices[defaultIndex].value);
return;
}
const n = parseInt(answer.trim(), 10);
if (n >= 1 && n <= choices.length) {
resolve(choices[n - 1].value);
} else {
console.log(" ⚠ Choix invalide.");
prompt();
}
});
};
prompt();
});
}
// ── HTTP ──────────────────────────────────────────────────────────────────────
function postJSON(url, data, apiKey) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === "https:" ? https : http;
const body = JSON.stringify(data);
const req = mod.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"X-API-Key": apiKey,
},
},
(res) => {
let raw = "";
res.on("data", (chunk) => (raw += chunk));
res.on("end", () => {
try {
resolve({ status: res.statusCode, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode, data: raw });
}
});
},
);
req.on("error", reject);
req.write(body);
req.end();
});
}
// ── Création d'un établissement ───────────────────────────────────────────────
async function createEstablishment(backendUrl, apiKey, payload) {
const url = `${backendUrl.replace(/\/$/, "")}/Establishment/establishments`;
const res = await postJSON(url, payload, apiKey);
return res;
}
// ── Validation basique d'un enregistrement batch ──────────────────────────────
function validateRecord(record, index) {
const errors = [];
const label = record.name ? `"${record.name}"` : `#${index + 1}`;
if (!record.name) errors.push("name manquant");
if (!record.address) errors.push("address manquant");
if (!record.total_capacity) errors.push("total_capacity manquant");
if (
!Array.isArray(record.establishment_type) ||
record.establishment_type.length === 0
)
errors.push("establishment_type manquant ou vide");
if (!record.directeur) errors.push("directeur manquant");
else {
if (!record.directeur.email) errors.push("directeur.email manquant");
if (!record.directeur.password) errors.push("directeur.password manquant");
if (!record.directeur.last_name)
errors.push("directeur.last_name manquant");
if (!record.directeur.first_name)
errors.push("directeur.first_name manquant");
}
if (errors.length > 0) {
throw new Error(`Établissement ${label} invalide : ${errors.join(", ")}`);
}
}
// ── Mode batch ────────────────────────────────────────────────────────────────
async function runBatch(filePath) {
const absPath = path.resolve(filePath);
if (!fs.existsSync(absPath)) {
console.error(`❌ Fichier introuvable : ${absPath}`);
process.exit(1);
}
let batch;
try {
batch = JSON.parse(fs.readFileSync(absPath, "utf8"));
} catch (err) {
console.error(`❌ Fichier JSON invalide : ${err.message}`);
process.exit(1);
}
const backendUrl =
batch.backendUrl || process.env.URL_DJANGO || "http://localhost:8080";
const apiKey = batch.apiKey || process.env.WEBHOOK_API_KEY;
if (!apiKey) {
console.error(
"❌ apiKey manquant dans le fichier batch ou la variable WEBHOOK_API_KEY.",
);
process.exit(1);
}
const establishments = batch.establishments;
if (!Array.isArray(establishments) || establishments.length === 0) {
console.error("❌ Le fichier batch ne contient aucun établissement.");
process.exit(1);
}
// Validation préalable de tous les enregistrements
establishments.forEach((record, i) => validateRecord(record, i));
console.log(
`\n📋 Batch : ${establishments.length} établissement(s) à créer sur ${backendUrl}\n`,
);
let success = 0;
let failure = 0;
for (let i = 0; i < establishments.length; i++) {
const record = establishments[i];
const label = `[${i + 1}/${establishments.length}] ${record.name}`;
process.stdout.write(` ${label} ... `);
try {
const res = await createEstablishment(backendUrl, apiKey, record);
if (res.status === 201) {
console.log(`✅ (ID: ${res.data.id})`);
success++;
} else {
console.log(`❌ HTTP ${res.status}`);
console.error(` ${JSON.stringify(res.data)}`);
failure++;
}
} catch (err) {
console.log(`❌ Erreur réseau : ${err.message}`);
failure++;
}
}
console.log(`\nRésultat : ${success} créé(s), ${failure} échec(s).`);
if (failure > 0) process.exit(1);
}
// ── Mode interactif ───────────────────────────────────────────────────────────
async function runInteractive() {
console.log("╔══════════════════════════════════════════╗");
console.log("║ Création d'un établissement N3WT ║");
console.log("╚══════════════════════════════════════════╝\n");
const backendUrl = await ask(
"URL du backend Django",
process.env.URL_DJANGO || "http://localhost:8080",
);
const apiKey = await ask(
"Clé API webhook (WEBHOOK_API_KEY)",
process.env.WEBHOOK_API_KEY || "",
);
if (!apiKey) {
console.error("❌ La clé API est obligatoire.");
getRL().close();
process.exit(1);
}
// --- Établissement ---
console.log("\n── Établissement ──");
const name = await askRequired("Nom de l'établissement");
const address = await askRequired("Adresse");
const totalCapacity = parseInt(await askRequired("Capacité totale"), 10);
const establishmentType = await askChoices("Type(s) de structure:", [
{ label: "Maternelle", value: 1 },
{ label: "Primaire", value: 2 },
{ label: "Secondaire", value: 3 },
]);
const evaluationFrequency = await askChoice(
"Fréquence d'évaluation:",
[
{ label: "Trimestre", value: 1 },
{ label: "Semestre", value: 2 },
{ label: "Année", value: 3 },
],
0,
);
const licenceCode = await ask("Code licence (optionnel)", "");
// --- Directeur (admin) ---
console.log("\n── Compte directeur (admin) ──");
const directorEmail = await askRequired("Email du directeur");
const directorPassword = await askRequired("Mot de passe");
const directorLastName = await askRequired("Nom de famille");
const directorFirstName = await askRequired("Prénom");
// --- Récapitulatif ---
console.log("\n── Récapitulatif ──");
console.log(` Établissement : ${name}`);
console.log(` Adresse : ${address}`);
console.log(` Capacité : ${totalCapacity}`);
console.log(` Type(s) : ${establishmentType.join(", ")}`);
console.log(` Évaluation : ${evaluationFrequency}`);
console.log(
` Directeur : ${directorFirstName} ${directorLastName} <${directorEmail}>`,
);
const confirm = await ask("\nConfirmer la création ? (o/n)", "o");
if (confirm.toLowerCase() !== "o") {
console.log("Annulé.");
getRL().close();
process.exit(0);
}
const payload = {
name,
address,
total_capacity: totalCapacity,
establishment_type: establishmentType,
evaluation_frequency: evaluationFrequency,
...(licenceCode && { licence_code: licenceCode }),
directeur: {
email: directorEmail,
password: directorPassword,
last_name: directorLastName,
first_name: directorFirstName,
},
};
console.log("\nCréation en cours...");
try {
const res = await createEstablishment(backendUrl, apiKey, payload);
if (res.status === 201) {
console.log("\n✅ Établissement créé avec succès !");
console.log(` ID : ${res.data.id}`);
console.log(` Nom : ${res.data.name}`);
} else {
console.error(`\n❌ Erreur (HTTP ${res.status}):`);
console.error(JSON.stringify(res.data, null, 2));
process.exit(1);
}
} catch (err) {
console.error("\n❌ Erreur réseau:", err.message);
process.exit(1);
}
getRL().close();
}
// ── Point d'entrée ────────────────────────────────────────────────────────────
async function main() {
loadBackendEnv();
const args = process.argv.slice(2);
const fileIndex = args.indexOf("--file");
if (fileIndex !== -1) {
const filePath = args[fileIndex + 1];
if (!filePath) {
console.error(
"❌ Argument --file manquant. Usage : --file <chemin/vers/batch.json>",
);
process.exit(1);
}
await runBatch(filePath);
} else {
await runInteractive();
}
}
main().catch((err) => {
console.error("❌ Erreur inattendue:", err.message);
process.exit(1);
});