mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-06-04 13:26:11 +00:00
Compare commits
12 Commits
4e50a0696f
...
79e14a23fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 79e14a23fe | |||
| 269866fb1c | |||
| f091fa0432 | |||
| a3291262d8 | |||
| 5f6c015d02 | |||
| 09b1541dc8 | |||
| cb76a23d02 | |||
| 2579af9b8b | |||
| 3a132ae0bd | |||
| 905fa5dbfb | |||
| edb9ace6ae | |||
| 6fb3c5cdb4 |
20
.github/copilot-instructions.md
vendored
20
.github/copilot-instructions.md
vendored
@ -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)
|
||||
|
||||
116
.github/instructions/design-system.instruction.md
vendored
Normal file
116
.github/instructions/design-system.instruction.md
vendored
Normal 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éé
|
||||
@ -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
1
.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
||||
hardcoded-strings-report.md
|
||||
backend.env
|
||||
*.log
|
||||
.claude/worktrees/*
|
||||
1
.husky/commit-msg
Normal file → Executable file
1
.husky/commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
npx --no -- commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file → Executable file
1
.husky/pre-commit
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
cd $(dirname "$0")/../Front-End/ && npm run lint-light
|
||||
1
.husky/prepare-commit-msg
Normal file → Executable file
1
.husky/prepare-commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||
@ -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"),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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})"
|
||||
@ -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}"
|
||||
@ -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"),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
91
CLAUDE.md
Normal 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/`
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,18 +565,34 @@ export default function Page() {
|
||||
title="Suivi pédagogique"
|
||||
description="Suivez le parcours d'un élève"
|
||||
/>
|
||||
<div className="relative flex-grow max-w-md">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un élève"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<SchoolYearFilter
|
||||
activeFilter={activeYearFilter}
|
||||
onFilterChange={setActiveYearFilter}
|
||||
showNextYear={true}
|
||||
showHistorical={true}
|
||||
/>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un élève"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors ml-4"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,48 +602,83 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
Appel du jour :{' '}
|
||||
<span className="ml-2 text-emerald-600">{today}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{!isEditingAttendance ? (
|
||||
<Button
|
||||
text="Faire l'appel"
|
||||
onClick={handleToggleAttendanceMode}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
text="Valider l'appel"
|
||||
onClick={handleValidateAttendance}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
/>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
name: 'Nom',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{row.last_name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Prénom',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{row.first_name}</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">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
Appel du jour :{' '}
|
||||
<span className="ml-2 text-emerald-600">{today}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{!isEditingAttendance ? (
|
||||
<Button
|
||||
text="Faire l'appel"
|
||||
onClick={handleToggleAttendanceMode}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
text="Valider l'appel"
|
||||
onClick={handleValidateAttendance}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
name: 'Nom',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{row.last_name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Prénom',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{row.first_name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
@ -530,7 +533,7 @@ export default function CreateSubscriptionPage() {
|
||||
'Succès'
|
||||
);
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsLoading(false);
|
||||
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,17 +898,27 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{profileRole !== 0 && (
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
)}
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
158
Front-End/src/components/Evaluation/EvaluationForm.js
Normal file
158
Front-End/src/components/Evaluation/EvaluationForm.js
Normal 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>
|
||||
);
|
||||
}
|
||||
299
Front-End/src/components/Evaluation/EvaluationGradeTable.js
Normal file
299
Front-End/src/components/Evaluation/EvaluationGradeTable.js
Normal 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>
|
||||
);
|
||||
}
|
||||
153
Front-End/src/components/Evaluation/EvaluationList.js
Normal file
153
Front-End/src/components/Evaluation/EvaluationList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
298
Front-End/src/components/Evaluation/EvaluationStudentView.js
Normal file
298
Front-End/src/components/Evaluation/EvaluationStudentView.js
Normal 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">
|
||||
"{ev.studentComment}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
4
Front-End/src/components/Evaluation/index.js
Normal file
4
Front-End/src/components/Evaluation/index.js
Normal 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';
|
||||
@ -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,9 +199,9 @@ 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const isDynamicForm = (template) =>
|
||||
template.formTemplateData &&
|
||||
@ -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>
|
||||
@ -337,38 +398,56 @@ export default function DynamicFormsList({
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{currentTemplate.name}
|
||||
{currentTemplate.name}
|
||||
</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,14 +484,15 @@ 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 && (
|
||||
<iframe
|
||||
src={`${BASE_URL}${currentTemplate.file}`}
|
||||
title={currentTemplate.name}
|
||||
className="w-full"
|
||||
style={{ height: '600px', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
{currentTemplate.isValidated === true &&
|
||||
currentTemplate.file && (
|
||||
<iframe
|
||||
src={getSecureFileUrl(currentTemplate.file)}
|
||||
title={currentTemplate.name}
|
||||
className="w-full"
|
||||
style={{ height: '600px', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cas non validé : bouton télécharger + upload */}
|
||||
{currentTemplate.isValidated !== true && (
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,9 +225,10 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
||||
]
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
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'
|
||||
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'
|
||||
}
|
||||
dropdownOpen={dropdownOpen}
|
||||
setDropdownOpen={setDropdownOpen}
|
||||
|
||||
52
Front-End/src/components/SchoolYearFilter.js
Normal file
52
Front-End/src/components/SchoolYearFilter.js
Normal file
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import Tab from '@/components/Tab';
|
||||
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
||||
import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants';
|
||||
|
||||
/**
|
||||
* Composant de filtre par année scolaire réutilisable.
|
||||
* Affiche des tabs pour l'année en cours, l'année prochaine et l'historique.
|
||||
*
|
||||
* @param {string} activeFilter - Le filtre actif ('current_year', 'next_year', 'historical')
|
||||
* @param {function} onFilterChange - Callback appelé quand le filtre change
|
||||
* @param {object} counts - Objet contenant les compteurs par filtre { currentYear, nextYear, historical }
|
||||
* @param {boolean} showNextYear - Afficher ou non l'onglet année prochaine (défaut: true)
|
||||
* @param {boolean} showHistorical - Afficher ou non l'onglet historique (défaut: true)
|
||||
*/
|
||||
const SchoolYearFilter = ({
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
counts = {},
|
||||
showNextYear = true,
|
||||
showHistorical = true,
|
||||
}) => {
|
||||
const currentSchoolYear = getCurrentSchoolYear();
|
||||
const nextSchoolYear = getNextSchoolYear();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Tab
|
||||
text={`${currentSchoolYear}${counts.currentYear !== undefined ? ` (${counts.currentYear})` : ''}`}
|
||||
active={activeFilter === CURRENT_YEAR_FILTER}
|
||||
onClick={() => onFilterChange(CURRENT_YEAR_FILTER)}
|
||||
/>
|
||||
{showNextYear && (
|
||||
<Tab
|
||||
text={`${nextSchoolYear}${counts.nextYear !== undefined ? ` (${counts.nextYear})` : ''}`}
|
||||
active={activeFilter === NEXT_YEAR_FILTER}
|
||||
onClick={() => onFilterChange(NEXT_YEAR_FILTER)}
|
||||
/>
|
||||
)}
|
||||
{showHistorical && (
|
||||
<Tab
|
||||
text={`Archives${counts.historical !== undefined ? ` (${counts.historical})` : ''}`}
|
||||
active={activeFilter === HISTORICAL_FILTER}
|
||||
onClick={() => onFilterChange(HISTORICAL_FILTER)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchoolYearFilter;
|
||||
@ -9,6 +9,7 @@ const SectionHeader = ({
|
||||
button = false,
|
||||
buttonOpeningModal = false,
|
||||
onClick = null,
|
||||
secondaryButton = null, // Bouton secondaire (ex: export)
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@ -29,18 +30,21 @@ const SectionHeader = ({
|
||||
<p className="text-sm text-gray-500 italic">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{button && onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
buttonOpeningModal
|
||||
? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300'
|
||||
: 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2'
|
||||
}
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{secondaryButton}
|
||||
{button && onClick && (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={
|
||||
buttonOpeningModal
|
||||
? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300'
|
||||
: 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2'
|
||||
}
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand, Download } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
@ -17,6 +17,8 @@ import { usePlanning } from '@/context/PlanningContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
const ItemTypes = {
|
||||
TEACHER: 'teacher',
|
||||
@ -115,6 +117,7 @@ const TeachersDropZone = ({
|
||||
|
||||
const ClassesSection = ({
|
||||
classes,
|
||||
allClasses,
|
||||
setClasses,
|
||||
teachers,
|
||||
handleCreate,
|
||||
@ -132,13 +135,15 @@ const ClassesSection = ({
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Les classes arrivent déjà filtrées depuis le parent
|
||||
useEffect(() => { setCurrentPage(1); }, [classes]);
|
||||
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
|
||||
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
|
||||
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
||||
const { getNiveauxLabels, allNiveaux } = useClasses();
|
||||
const { getNiveauxLabels, getNiveauLabel, allNiveaux } = useClasses();
|
||||
const router = useRouter();
|
||||
|
||||
// Fonction pour générer les années scolaires
|
||||
@ -188,6 +193,30 @@ const ClassesSection = ({
|
||||
});
|
||||
};
|
||||
|
||||
// Export CSV
|
||||
const handleExportCSV = () => {
|
||||
const exportColumns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'atmosphere_name', label: "Nom d'ambiance" },
|
||||
{ key: 'age_range', label: "Tranche d'âge" },
|
||||
{
|
||||
key: 'levels',
|
||||
label: 'Niveaux',
|
||||
transform: (value) => value ? value.map(l => getNiveauLabel(l)).join(', ') : ''
|
||||
},
|
||||
{ key: 'number_of_students', label: 'Capacité max' },
|
||||
{ key: 'school_year', label: 'Année scolaire' },
|
||||
{
|
||||
key: 'teachers_details',
|
||||
label: 'Enseignants',
|
||||
transform: (value) => value ? value.map(t => `${t.last_name} ${t.first_name}`).join(', ') : ''
|
||||
},
|
||||
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||
];
|
||||
const filename = `classes_${new Date().toISOString().split('T')[0]}`;
|
||||
exportToCSV(classes, exportColumns, filename);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
@ -559,6 +588,16 @@ const ClassesSection = ({
|
||||
description="Gérez les classes de votre école"
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddClass}
|
||||
secondaryButton={
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
|
||||
import { Trash2, Edit3, Check, X, BookOpen, Download } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||
import logger from '@/utils/logger';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
const SpecialitiesSection = ({
|
||||
specialities,
|
||||
allSpecialities,
|
||||
setSpecialities,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
@ -36,6 +40,21 @@ const SpecialitiesSection = ({
|
||||
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedSchoolYear } = useSchoolYearFilter();
|
||||
|
||||
// Fonction pour générer les années scolaires
|
||||
const getSchoolYearChoices = () => {
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
const startYear = currentMonth >= 9 ? currentYear : currentYear - 1;
|
||||
const choices = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const year = startYear + i;
|
||||
choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` });
|
||||
}
|
||||
return choices;
|
||||
};
|
||||
|
||||
// Récupération des messages d'erreur
|
||||
const getError = (field) => {
|
||||
@ -43,7 +62,20 @@ const SpecialitiesSection = ({
|
||||
};
|
||||
|
||||
const handleAddSpeciality = () => {
|
||||
setNewSpeciality({ id: Date.now(), name: '', color_code: '' });
|
||||
setNewSpeciality({ id: Date.now(), name: '', color_code: '', school_year: selectedSchoolYear });
|
||||
};
|
||||
|
||||
// Export CSV
|
||||
const handleExportCSV = () => {
|
||||
const exportColumns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Nom' },
|
||||
{ key: 'color_code', label: 'Code couleur' },
|
||||
{ key: 'school_year', label: 'Année scolaire' },
|
||||
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||
];
|
||||
const filename = `specialites_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`;
|
||||
exportToCSV(specialities, exportColumns, filename);
|
||||
};
|
||||
|
||||
const handleRemoveSpeciality = (id) => {
|
||||
@ -150,6 +182,20 @@ const SpecialitiesSection = ({
|
||||
errorMsg={getError('name')}
|
||||
/>
|
||||
);
|
||||
case 'ANNÉE SCOLAIRE':
|
||||
return (
|
||||
<SelectChoice
|
||||
type="select"
|
||||
name="school_year"
|
||||
placeHolder="Sélectionnez une année scolaire"
|
||||
choices={getSchoolYearChoices()}
|
||||
callback={handleChange}
|
||||
selected={currentData.school_year || ''}
|
||||
errorMsg={getError('school_year')}
|
||||
IconItem={null}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
case 'ACTIONS':
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
@ -184,6 +230,8 @@ const SpecialitiesSection = ({
|
||||
switch (column) {
|
||||
case 'LIBELLE':
|
||||
return <SpecialityItem key={speciality.id} speciality={speciality} />;
|
||||
case 'ANNÉE SCOLAIRE':
|
||||
return speciality.school_year;
|
||||
case 'MISE A JOUR':
|
||||
return speciality.updated_date_formatted;
|
||||
case 'ACTIONS':
|
||||
@ -244,6 +292,7 @@ const SpecialitiesSection = ({
|
||||
|
||||
const columns = [
|
||||
{ name: 'LIBELLE', label: 'Libellé' },
|
||||
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
|
||||
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
|
||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||
];
|
||||
@ -257,6 +306,16 @@ const SpecialitiesSection = ({
|
||||
description="Gérez les spécialités de votre école"
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddSpeciality}
|
||||
secondaryButton={
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
|
||||
|
||||
@ -1,14 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection';
|
||||
import TeachersSection from '@/components/Structure/Configuration/TeachersSection';
|
||||
import ClassesSection from '@/components/Structure/Configuration/ClassesSection';
|
||||
import { ClassesProvider } from '@/context/ClassesContext';
|
||||
import { SchoolYearFilterProvider, useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||
import SchoolYearFilter from '@/components/SchoolYearFilter';
|
||||
import {
|
||||
BE_SCHOOL_SPECIALITIES_URL,
|
||||
BE_SCHOOL_TEACHERS_URL,
|
||||
BE_SCHOOL_SCHOOLCLASSES_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
const StructureManagementContent = ({
|
||||
specialities,
|
||||
setSpecialities,
|
||||
teachers,
|
||||
setTeachers,
|
||||
classes,
|
||||
setClasses,
|
||||
profiles,
|
||||
handleCreate,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}) => {
|
||||
const { activeYearFilter, setActiveYearFilter, filterByYear } = useSchoolYearFilter();
|
||||
|
||||
// Filtrer les données par année scolaire
|
||||
const filteredSpecialities = useMemo(() => filterByYear(specialities), [specialities, filterByYear]);
|
||||
const filteredTeachers = useMemo(() => filterByYear(teachers), [teachers, filterByYear]);
|
||||
const filteredClasses = useMemo(() => filterByYear(classes), [classes, filterByYear]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<SchoolYearFilter
|
||||
activeFilter={activeYearFilter}
|
||||
onFilterChange={setActiveYearFilter}
|
||||
showNextYear={true}
|
||||
showHistorical={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
|
||||
<div className="mt-8 flex flex-col xl:flex-row gap-8">
|
||||
<div className="w-full xl:w-2/5">
|
||||
<SpecialitiesSection
|
||||
specialities={filteredSpecialities}
|
||||
allSpecialities={specialities}
|
||||
setSpecialities={setSpecialities}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||
newData,
|
||||
setSpecialities
|
||||
)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setSpecialities
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full xl:flex-1">
|
||||
<TeachersSection
|
||||
teachers={filteredTeachers}
|
||||
allTeachers={teachers}
|
||||
setTeachers={setTeachers}
|
||||
specialities={filteredSpecialities}
|
||||
profiles={profiles}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_TEACHERS_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setTeachers
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-8 xl:mt-12">
|
||||
<ClassesSection
|
||||
classes={filteredClasses}
|
||||
allClasses={classes}
|
||||
setClasses={setClasses}
|
||||
teachers={filteredTeachers}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||
newData,
|
||||
setClasses
|
||||
)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setClasses
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StructureManagement = ({
|
||||
specialities,
|
||||
setSpecialities,
|
||||
@ -23,82 +135,22 @@ const StructureManagement = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ClassesProvider>
|
||||
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
|
||||
<div className="mt-8 flex flex-col xl:flex-row gap-8">
|
||||
<div className="w-full xl:w-2/5">
|
||||
<SpecialitiesSection
|
||||
specialities={specialities}
|
||||
setSpecialities={setSpecialities}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||
newData,
|
||||
setSpecialities
|
||||
)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setSpecialities
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full xl:flex-1">
|
||||
<TeachersSection
|
||||
teachers={teachers}
|
||||
setTeachers={setTeachers}
|
||||
specialities={specialities}
|
||||
profiles={profiles}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_TEACHERS_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setTeachers
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-8 xl:mt-12">
|
||||
<ClassesSection
|
||||
<SchoolYearFilterProvider>
|
||||
<ClassesProvider>
|
||||
<StructureManagementContent
|
||||
specialities={specialities}
|
||||
setSpecialities={setSpecialities}
|
||||
teachers={teachers}
|
||||
setTeachers={setTeachers}
|
||||
classes={classes}
|
||||
setClasses={setClasses}
|
||||
teachers={teachers}
|
||||
handleCreate={(newData) =>
|
||||
handleCreate(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||
newData,
|
||||
setClasses
|
||||
)
|
||||
}
|
||||
handleEdit={(id, updatedData) =>
|
||||
handleEdit(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||
id,
|
||||
updatedData,
|
||||
setClasses
|
||||
)
|
||||
}
|
||||
handleDelete={(id) =>
|
||||
handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)
|
||||
}
|
||||
profiles={profiles}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</ClassesProvider>
|
||||
</ClassesProvider>
|
||||
</SchoolYearFilterProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
|
||||
import { Edit3, Trash2, GraduationCap, Check, X, Hand, Download } from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
@ -10,8 +11,10 @@ import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'
|
||||
import TeacherItem from './TeacherItem';
|
||||
import logger from '@/utils/logger';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
const ItemTypes = {
|
||||
SPECIALITY: 'speciality',
|
||||
@ -120,6 +123,7 @@ const SpecialitiesDropZone = ({
|
||||
|
||||
const TeachersSection = ({
|
||||
teachers,
|
||||
allTeachers,
|
||||
setTeachers,
|
||||
specialities,
|
||||
profiles,
|
||||
@ -145,6 +149,22 @@ const TeachersSection = ({
|
||||
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedSchoolYear } = useSchoolYearFilter();
|
||||
|
||||
// Génère les choix d'année scolaire (année en cours + 2 suivantes)
|
||||
const getSchoolYearChoices = () => {
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
// Si on est avant septembre, l'année scolaire a commencé l'année précédente
|
||||
const startYear = currentMonth >= 9 ? currentYear : currentYear - 1;
|
||||
const choices = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const year = startYear + i;
|
||||
choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` });
|
||||
}
|
||||
return choices;
|
||||
};
|
||||
|
||||
// --- UTILS ---
|
||||
|
||||
@ -197,6 +217,7 @@ const TeachersSection = ({
|
||||
associated_profile_email: '',
|
||||
specialities: [],
|
||||
role_type: 0,
|
||||
school_year: selectedSchoolYear,
|
||||
});
|
||||
setFormData({
|
||||
last_name: '',
|
||||
@ -204,9 +225,34 @@ const TeachersSection = ({
|
||||
associated_profile_email: '',
|
||||
specialities: [],
|
||||
role_type: 0,
|
||||
school_year: selectedSchoolYear,
|
||||
});
|
||||
};
|
||||
|
||||
// Export CSV
|
||||
const handleExportCSV = () => {
|
||||
const exportColumns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'last_name', label: 'Nom' },
|
||||
{ key: 'first_name', label: 'Prénom' },
|
||||
{ key: 'associated_profile_email', label: 'Email' },
|
||||
{
|
||||
key: 'specialities_details',
|
||||
label: 'Spécialités',
|
||||
transform: (value) => value ? value.map(s => s.name).join(', ') : ''
|
||||
},
|
||||
{
|
||||
key: 'role_type',
|
||||
label: 'Profil',
|
||||
transform: (value) => value === 1 ? 'Administrateur' : 'Enseignant'
|
||||
},
|
||||
{ key: 'school_year', label: 'Année scolaire' },
|
||||
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||
];
|
||||
const filename = `enseignants_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`;
|
||||
exportToCSV(teachers, exportColumns, filename);
|
||||
};
|
||||
|
||||
const handleRemoveTeacher = (id) => {
|
||||
logger.debug('[DELETE] Suppression teacher id:', id);
|
||||
return handleDelete(id)
|
||||
@ -242,6 +288,7 @@ const TeachersSection = ({
|
||||
},
|
||||
}),
|
||||
},
|
||||
school_year: formData.school_year || selectedSchoolYear,
|
||||
specialities: formData.specialities || [],
|
||||
};
|
||||
|
||||
@ -293,6 +340,7 @@ const TeachersSection = ({
|
||||
handleEdit(id, {
|
||||
last_name: updatedData.last_name,
|
||||
first_name: updatedData.first_name,
|
||||
school_year: updatedData.school_year || selectedSchoolYear,
|
||||
profile_role_data: profileRoleData,
|
||||
specialities: updatedData.specialities || [],
|
||||
})
|
||||
@ -391,6 +439,20 @@ const TeachersSection = ({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'ANNÉE SCOLAIRE':
|
||||
return (
|
||||
<SelectChoice
|
||||
type="select"
|
||||
name="school_year"
|
||||
placeHolder="Sélectionnez une année scolaire"
|
||||
choices={getSchoolYearChoices()}
|
||||
callback={handleChange}
|
||||
selected={currentData.school_year || ''}
|
||||
errorMsg={getError('school_year')}
|
||||
IconItem={null}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
case 'ACTIONS':
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
@ -459,6 +521,8 @@ const TeachersSection = ({
|
||||
} else {
|
||||
return <i>Non définie</i>;
|
||||
}
|
||||
case 'ANNÉE SCOLAIRE':
|
||||
return teacher.school_year;
|
||||
case 'MISE A JOUR':
|
||||
return teacher.updated_date_formatted;
|
||||
case 'ACTIONS':
|
||||
@ -526,6 +590,7 @@ const TeachersSection = ({
|
||||
{ name: 'EMAIL', label: 'Email' },
|
||||
{ name: 'SPECIALITES', label: 'Spécialités' },
|
||||
{ name: 'ADMINISTRATEUR', label: 'Profil' },
|
||||
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
|
||||
{ name: 'MISE A JOUR', label: 'Mise à jour' },
|
||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||
];
|
||||
@ -539,6 +604,16 @@ const TeachersSection = ({
|
||||
description="Gérez les enseignants.es de votre école"
|
||||
button={profileRole !== 0}
|
||||
onClick={handleAddTeacher}
|
||||
secondaryButton={
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
85
Front-End/src/context/SchoolYearFilterContext.js
Normal file
85
Front-End/src/context/SchoolYearFilterContext.js
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
import React, { createContext, useContext, useState, useMemo } from 'react';
|
||||
import { getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears } from '@/utils/Date';
|
||||
import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants';
|
||||
|
||||
const SchoolYearFilterContext = createContext(null);
|
||||
|
||||
/**
|
||||
* Provider pour le filtre d'année scolaire partagé entre plusieurs composants.
|
||||
* Permet d'avoir un seul filtre qui s'applique sur plusieurs tableaux.
|
||||
*/
|
||||
export const SchoolYearFilterProvider = ({ children }) => {
|
||||
const [activeYearFilter, setActiveYearFilter] = useState(CURRENT_YEAR_FILTER);
|
||||
|
||||
const currentSchoolYear = useMemo(() => getCurrentSchoolYear(), []);
|
||||
const nextSchoolYear = useMemo(() => getNextSchoolYear(), []);
|
||||
const historicalYears = useMemo(() => getHistoricalYears(5), []);
|
||||
|
||||
/**
|
||||
* Retourne l'année scolaire sélectionnée selon le filtre actif.
|
||||
*/
|
||||
const selectedSchoolYear = useMemo(() => {
|
||||
if (activeYearFilter === CURRENT_YEAR_FILTER) return currentSchoolYear;
|
||||
if (activeYearFilter === NEXT_YEAR_FILTER) return nextSchoolYear;
|
||||
return historicalYears[0];
|
||||
}, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]);
|
||||
|
||||
/**
|
||||
* Filtre un tableau d'éléments par année scolaire.
|
||||
* @param {Array} items - Les éléments à filtrer
|
||||
* @param {string} yearField - Le nom du champ contenant l'année scolaire (défaut: 'school_year')
|
||||
*/
|
||||
const filterByYear = useMemo(() => (items, yearField = 'school_year') => {
|
||||
if (!items) return [];
|
||||
if (activeYearFilter === CURRENT_YEAR_FILTER) {
|
||||
return items.filter((item) => item[yearField] === currentSchoolYear);
|
||||
} else if (activeYearFilter === NEXT_YEAR_FILTER) {
|
||||
return items.filter((item) => item[yearField] === nextSchoolYear);
|
||||
} else if (activeYearFilter === HISTORICAL_FILTER) {
|
||||
return items.filter((item) => historicalYears.includes(item[yearField]));
|
||||
}
|
||||
return items;
|
||||
}, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]);
|
||||
|
||||
/**
|
||||
* Calcule les compteurs pour chaque filtre.
|
||||
* @param {Array} items - Les éléments à compter
|
||||
* @param {string} yearField - Le nom du champ contenant l'année scolaire
|
||||
*/
|
||||
const getYearCounts = useMemo(() => (items, yearField = 'school_year') => {
|
||||
if (!items) return { currentYear: 0, nextYear: 0, historical: 0 };
|
||||
return {
|
||||
currentYear: items.filter((item) => item[yearField] === currentSchoolYear).length,
|
||||
nextYear: items.filter((item) => item[yearField] === nextSchoolYear).length,
|
||||
historical: items.filter((item) => historicalYears.includes(item[yearField])).length,
|
||||
};
|
||||
}, [currentSchoolYear, nextSchoolYear, historicalYears]);
|
||||
|
||||
const value = {
|
||||
activeYearFilter,
|
||||
setActiveYearFilter,
|
||||
currentSchoolYear,
|
||||
nextSchoolYear,
|
||||
historicalYears,
|
||||
selectedSchoolYear,
|
||||
filterByYear,
|
||||
getYearCounts,
|
||||
};
|
||||
|
||||
return (
|
||||
<SchoolYearFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</SchoolYearFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSchoolYearFilter = () => {
|
||||
const context = useContext(SchoolYearFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useSchoolYearFilter must be used within a SchoolYearFilterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default SchoolYearFilterContext;
|
||||
54
Front-End/src/pages/api/download.js
Normal file
54
Front-End/src/pages/api/download.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
@ -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`;
|
||||
|
||||
93
Front-End/src/utils/exportCSV.js
Normal file
93
Front-End/src/utils/exportCSV.js
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Utilitaire d'export CSV
|
||||
* Génère et télécharge un fichier CSV à partir de données
|
||||
*/
|
||||
|
||||
/**
|
||||
* Échappe les valeurs CSV (guillemets, virgules, retours à la ligne)
|
||||
* @param {*} value - La valeur à échapper
|
||||
* @returns {string} - La valeur échappée
|
||||
*/
|
||||
const escapeCSVValue = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const stringValue = String(value);
|
||||
// Si la valeur contient des guillemets, des virgules ou des retours à la ligne
|
||||
if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes(';')) {
|
||||
// Échapper les guillemets en les doublant et entourer de guillemets
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convertit un tableau d'objets en chaîne CSV
|
||||
* @param {Array<Object>} data - Les données à convertir
|
||||
* @param {Array<{key: string, label: string, transform?: Function}>} columns - Configuration des colonnes
|
||||
* @param {string} separator - Le séparateur (par défaut ';' pour compatibilité Excel FR)
|
||||
* @returns {string} - La chaîne CSV
|
||||
*/
|
||||
export const convertToCSV = (data, columns, separator = ';') => {
|
||||
if (!data || data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// En-têtes
|
||||
const headers = columns.map((col) => escapeCSVValue(col.label)).join(separator);
|
||||
|
||||
// Lignes de données
|
||||
const rows = data.map((item) => {
|
||||
return columns
|
||||
.map((col) => {
|
||||
let value = item[col.key];
|
||||
// Appliquer la transformation si elle existe
|
||||
if (col.transform && typeof col.transform === 'function') {
|
||||
value = col.transform(value, item);
|
||||
}
|
||||
return escapeCSVValue(value);
|
||||
})
|
||||
.join(separator);
|
||||
});
|
||||
|
||||
return [headers, ...rows].join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Télécharge un fichier CSV
|
||||
* @param {string} csvContent - Le contenu CSV
|
||||
* @param {string} filename - Le nom du fichier (sans extension)
|
||||
*/
|
||||
export const downloadCSV = (csvContent, filename) => {
|
||||
// Ajouter le BOM UTF-8 pour compatibilité Excel
|
||||
const BOM = '\uFEFF';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
|
||||
if (navigator.msSaveBlob) {
|
||||
// IE 10+
|
||||
navigator.msSaveBlob(blob, `${filename}.csv`);
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.href = url;
|
||||
link.download = `${filename}.csv`;
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exporte des données vers un fichier CSV et le télécharge
|
||||
* @param {Array<Object>} data - Les données à exporter
|
||||
* @param {Array<{key: string, label: string, transform?: Function}>} columns - Configuration des colonnes
|
||||
* @param {string} filename - Le nom du fichier (sans extension)
|
||||
*/
|
||||
export const exportToCSV = (data, columns, filename) => {
|
||||
const csvContent = convertToCSV(data, columns);
|
||||
downloadCSV(csvContent, filename);
|
||||
};
|
||||
|
||||
export default exportToCSV;
|
||||
25
Front-End/src/utils/fileUrl.js
Normal file
25
Front-End/src/utils/fileUrl.js
Normal 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)}`;
|
||||
};
|
||||
@ -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
293
docs/design-system.md
Normal 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.
|
||||
@ -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": {
|
||||
|
||||
437
scripts/create-establishment.js
Normal file
437
scripts/create-establishment.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user