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)
|
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
|
||||||
- Référence : [documentation guidelines](./instructions/documentation.instruction.md)
|
- 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
|
## Références
|
||||||
|
|
||||||
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
||||||
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
||||||
- **Tests** : [run tests](./instructions/run-tests.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.
|
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.
|
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.
|
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.
|
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
|
hardcoded-strings-report.md
|
||||||
backend.env
|
backend.env
|
||||||
*.log
|
*.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
|
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
|
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"
|
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||||
@ -3,6 +3,7 @@ from django.urls import path, re_path
|
|||||||
from .views import (
|
from .views import (
|
||||||
DomainListCreateView, DomainDetailView,
|
DomainListCreateView, DomainDetailView,
|
||||||
CategoryListCreateView, CategoryDetailView,
|
CategoryListCreateView, CategoryDetailView,
|
||||||
|
ServeFileView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -11,4 +12,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
|
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"),
|
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.http.response import JsonResponse
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@ -117,3 +122,55 @@ class CategoryDetailView(APIView):
|
|||||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
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)
|
name = models.CharField(max_length=100)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
color_code = models.CharField(max_length=7, default='#FFFFFF')
|
color_code = models.CharField(max_length=7, default='#FFFFFF')
|
||||||
|
school_year = models.CharField(max_length=9, blank=True)
|
||||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='specialities')
|
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='specialities')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -31,6 +32,7 @@ class Teacher(models.Model):
|
|||||||
first_name = models.CharField(max_length=100)
|
first_name = models.CharField(max_length=100)
|
||||||
specialities = models.ManyToManyField(Speciality, blank=True)
|
specialities = models.ManyToManyField(Speciality, blank=True)
|
||||||
profile_role = models.OneToOneField('Auth.ProfileRole', on_delete=models.CASCADE, related_name='teacher_profile', null=True, blank=True)
|
profile_role = models.OneToOneField('Auth.ProfileRole', on_delete=models.CASCADE, related_name='teacher_profile', null=True, blank=True)
|
||||||
|
school_year = models.CharField(max_length=9, blank=True)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -48,6 +50,7 @@ class SchoolClass(models.Model):
|
|||||||
number_of_students = models.PositiveIntegerField(null=True, blank=True)
|
number_of_students = models.PositiveIntegerField(null=True, blank=True)
|
||||||
teaching_language = models.CharField(max_length=255, blank=True)
|
teaching_language = models.CharField(max_length=255, blank=True)
|
||||||
school_year = models.CharField(max_length=9, blank=True)
|
school_year = models.CharField(max_length=9, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
teachers = models.ManyToManyField(Teacher, blank=True)
|
teachers = models.ManyToManyField(Teacher, blank=True)
|
||||||
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')
|
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')
|
||||||
@ -156,3 +159,26 @@ class EstablishmentCompetency(models.Model):
|
|||||||
if self.competency:
|
if self.competency:
|
||||||
return f"{self.establishment.name} - {self.competency.name}"
|
return f"{self.establishment.name} - {self.competency.name}"
|
||||||
return f"{self.establishment.name} - {self.custom_name} (custom)"
|
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,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
Competency
|
Competency,
|
||||||
|
Evaluation
|
||||||
)
|
)
|
||||||
from Auth.models import Profile, ProfileRole
|
from Auth.models import Profile, ProfileRole
|
||||||
from Subscriptions.models import Student
|
from Subscriptions.models import Student, StudentEvaluation
|
||||||
from Establishment.models import Establishment
|
from Establishment.models import Establishment
|
||||||
from Auth.serializers import ProfileRoleSerializer
|
from Auth.serializers import ProfileRoleSerializer
|
||||||
from N3wtSchool import settings
|
from N3wtSchool import settings
|
||||||
@ -182,12 +183,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
|
|||||||
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
||||||
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
||||||
teachers_details = serializers.SerializerMethodField()
|
teachers_details = serializers.SerializerMethodField()
|
||||||
students = StudentDetailSerializer(many=True, read_only=True)
|
students = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SchoolClass
|
model = SchoolClass
|
||||||
fields = '__all__'
|
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):
|
def create(self, validated_data):
|
||||||
teachers_data = validated_data.pop('teachers', [])
|
teachers_data = validated_data.pop('teachers', [])
|
||||||
levels_data = validated_data.pop('levels', [])
|
levels_data = validated_data.pop('levels', [])
|
||||||
@ -300,3 +306,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = PaymentMode
|
model = PaymentMode
|
||||||
fields = '__all__'
|
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,
|
PaymentModeListCreateView, PaymentModeDetailView,
|
||||||
CompetencyListCreateView, CompetencyDetailView,
|
CompetencyListCreateView, CompetencyDetailView,
|
||||||
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
||||||
|
EvaluationListCreateView, EvaluationDetailView,
|
||||||
|
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
|
||||||
|
SchoolYearsListView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -43,4 +46,16 @@ urlpatterns = [
|
|||||||
|
|
||||||
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
|
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"),
|
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,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
Competency
|
Competency,
|
||||||
|
Evaluation
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TeacherSerializer,
|
TeacherSerializer,
|
||||||
@ -28,19 +29,43 @@ from .serializers import (
|
|||||||
PaymentPlanSerializer,
|
PaymentPlanSerializer,
|
||||||
PaymentModeSerializer,
|
PaymentModeSerializer,
|
||||||
EstablishmentCompetencySerializer,
|
EstablishmentCompetencySerializer,
|
||||||
CompetencySerializer
|
CompetencySerializer,
|
||||||
|
EvaluationSerializer,
|
||||||
|
StudentEvaluationSerializer
|
||||||
)
|
)
|
||||||
from Common.models import Domain, Category
|
from Common.models import Domain, Category
|
||||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from Subscriptions.models import Student, StudentCompetency
|
from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
|
||||||
from Subscriptions.util import getCurrentSchoolYear
|
from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears
|
||||||
import logging
|
import logging
|
||||||
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
|
class SchoolYearsListView(APIView):
|
||||||
|
"""
|
||||||
|
Liste les années scolaires disponibles pour l'historique.
|
||||||
|
Retourne l'année en cours, la suivante, et les années historiques.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
current_year = getCurrentSchoolYear()
|
||||||
|
next_year = getNextSchoolYear()
|
||||||
|
historical_years = getHistoricalYears(5)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'current_year': current_year,
|
||||||
|
'next_year': next_year,
|
||||||
|
'historical_years': historical_years,
|
||||||
|
'all_years': [next_year, current_year] + historical_years
|
||||||
|
}, safe=False)
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class SpecialityListCreateView(APIView):
|
class SpecialityListCreateView(APIView):
|
||||||
@ -183,12 +208,33 @@ class SchoolClassListCreateView(APIView):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
|
school_year = request.GET.get('school_year', None)
|
||||||
|
year_filter = request.GET.get('year_filter', None) # 'current_year', 'next_year', 'historical'
|
||||||
|
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
school_classes_list = getAllObjects(SchoolClass)
|
school_classes_list = getAllObjects(SchoolClass)
|
||||||
if school_classes_list:
|
if school_classes_list:
|
||||||
school_classes_list = school_classes_list.filter(establishment=establishment_id).distinct()
|
school_classes_list = school_classes_list.filter(establishment=establishment_id)
|
||||||
|
|
||||||
|
# Filtrage par année scolaire
|
||||||
|
if school_year:
|
||||||
|
school_classes_list = school_classes_list.filter(school_year=school_year)
|
||||||
|
elif year_filter:
|
||||||
|
current_year = getCurrentSchoolYear()
|
||||||
|
next_year = getNextSchoolYear()
|
||||||
|
historical_years = getHistoricalYears(5)
|
||||||
|
|
||||||
|
if year_filter == 'current_year':
|
||||||
|
school_classes_list = school_classes_list.filter(school_year=current_year)
|
||||||
|
elif year_filter == 'next_year':
|
||||||
|
school_classes_list = school_classes_list.filter(school_year=next_year)
|
||||||
|
elif year_filter == 'historical':
|
||||||
|
school_classes_list = school_classes_list.filter(school_year__in=historical_years)
|
||||||
|
|
||||||
|
school_classes_list = school_classes_list.distinct()
|
||||||
|
|
||||||
classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
|
classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
|
||||||
return JsonResponse(classes_serializer.data, safe=False)
|
return JsonResponse(classes_serializer.data, safe=False)
|
||||||
|
|
||||||
@ -785,3 +831,179 @@ class EstablishmentCompetencyDetailView(APIView):
|
|||||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||||
except EstablishmentCompetency.DoesNotExist:
|
except EstablishmentCompetency.DoesNotExist:
|
||||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
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
|
# One-to-Many Relationship
|
||||||
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
|
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
|
||||||
|
|
||||||
|
# Audit fields
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.last_name + "_" + self.first_name
|
return self.last_name + "_" + self.first_name
|
||||||
|
|
||||||
@ -252,6 +256,7 @@ class RegistrationForm(models.Model):
|
|||||||
# One-to-One Relationship
|
# One-to-One Relationship
|
||||||
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
|
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
|
||||||
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE)
|
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
last_update = models.DateTimeField(auto_now=True)
|
last_update = models.DateTimeField(auto_now=True)
|
||||||
school_year = models.CharField(max_length=9, default="", blank=True)
|
school_year = models.CharField(max_length=9, default="", blank=True)
|
||||||
notes = models.CharField(max_length=200, blank=True)
|
notes = models.CharField(max_length=200, blank=True)
|
||||||
@ -578,6 +583,8 @@ class StudentCompetency(models.Model):
|
|||||||
default="",
|
default="",
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('student', 'establishment_competency', 'period')
|
unique_together = ('student', 'establishment_competency', 'period')
|
||||||
@ -589,6 +596,27 @@ class StudentCompetency(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
|
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) #######
|
####### Parent files templates (par dossier d'inscription) #######
|
||||||
class RegistrationParentFileTemplate(models.Model):
|
class RegistrationParentFileTemplate(models.Model):
|
||||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
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",
|
description="ID de l'établissement",
|
||||||
type=openapi.TYPE_INTEGER,
|
type=openapi.TYPE_INTEGER,
|
||||||
required=True
|
required=True
|
||||||
|
),
|
||||||
|
openapi.Parameter(
|
||||||
|
'school_year', openapi.IN_QUERY,
|
||||||
|
description="Année scolaire (ex: 2025-2026)",
|
||||||
|
type=openapi.TYPE_STRING,
|
||||||
|
required=False
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -61,6 +67,7 @@ class StudentListView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel
|
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel
|
||||||
|
school_year_filter = request.GET.get('school_year', None) # Filtre année scolaire
|
||||||
|
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -70,6 +77,9 @@ class StudentListView(APIView):
|
|||||||
if status_filter:
|
if status_filter:
|
||||||
students_qs = students_qs.filter(registrationform__status=status_filter)
|
students_qs = students_qs.filter(registrationform__status=status_filter)
|
||||||
|
|
||||||
|
if school_year_filter:
|
||||||
|
students_qs = students_qs.filter(registrationform__school_year=school_year_filter)
|
||||||
|
|
||||||
students_qs = students_qs.distinct()
|
students_qs = students_qs.distinct()
|
||||||
students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
|
students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
|
||||||
return JsonResponse(students_serializer.data, safe=False)
|
return JsonResponse(students_serializer.data, safe=False)
|
||||||
|
|||||||
@ -13,8 +13,11 @@ def run_command(command):
|
|||||||
|
|
||||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
||||||
flush_data = os.getenv('flush_data', '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 = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||||
|
migrate_data=True
|
||||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||||
|
watch_mode=True
|
||||||
|
|
||||||
collect_static_cmd = [
|
collect_static_cmd = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"]
|
["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 Attendance from '@/components/Grades/Attendance';
|
||||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
|
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import logger from '@/utils/logger';
|
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 {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
@ -15,9 +17,14 @@ import {
|
|||||||
editAbsences,
|
editAbsences,
|
||||||
deleteAbsences,
|
deleteAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import {
|
||||||
|
fetchEvaluations,
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
updateStudentEvaluation,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { Award, ArrowLeft } from 'lucide-react';
|
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
|
||||||
@ -46,6 +53,10 @@ export default function StudentGradesPage() {
|
|||||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
const [allAbsences, setAllAbsences] = useState([]);
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
|
||||||
|
// Evaluation states
|
||||||
|
const [evaluations, setEvaluations] = useState([]);
|
||||||
|
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||||
|
|
||||||
const getPeriods = () => {
|
const getPeriods = () => {
|
||||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||||
return [
|
return [
|
||||||
@ -135,6 +146,38 @@ export default function StudentGradesPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedEstablishmentId]);
|
}, [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(() => {
|
const absences = React.useMemo(() => {
|
||||||
return allAbsences
|
return allAbsences
|
||||||
.filter((a) => a.student === studentId)
|
.filter((a) => a.student === studentId)
|
||||||
@ -152,8 +195,12 @@ export default function StudentGradesPage() {
|
|||||||
const handleToggleJustify = (absence) => {
|
const handleToggleJustify = (absence) => {
|
||||||
const newReason =
|
const newReason =
|
||||||
absence.type === 'Absence'
|
absence.type === 'Absence'
|
||||||
? absence.justified ? 2 : 1
|
? absence.justified
|
||||||
: absence.justified ? 4 : 3;
|
? 2
|
||||||
|
: 1
|
||||||
|
: absence.justified
|
||||||
|
? 4
|
||||||
|
: 3;
|
||||||
|
|
||||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||||
.then(() => {
|
.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) => {
|
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 (
|
return (
|
||||||
<div className="p-4 md:p-8 space-y-6">
|
<div className="p-4 md:p-8 space-y-6">
|
||||||
{/* Header */}
|
{/* 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">
|
<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 ? (
|
{student.photo ? (
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${student.photo}`}
|
src={getSecureFileUrl(student.photo)}
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
||||||
/>
|
/>
|
||||||
@ -280,6 +349,22 @@ export default function StudentGradesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,45 +1,79 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Award, Eye, Search } from 'lucide-react';
|
import {
|
||||||
|
Award,
|
||||||
|
Eye,
|
||||||
|
Search,
|
||||||
|
BarChart2,
|
||||||
|
X,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
Download
|
||||||
|
} from 'lucide-react';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import {
|
import {
|
||||||
BASE_URL,
|
|
||||||
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||||
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
fetchAbsences,
|
fetchAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import {
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
updateStudentEvaluation,
|
||||||
|
deleteStudentEvaluation,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
import { exportToCSV } from '@/utils/exportCSV';
|
||||||
|
import SchoolYearFilter from '@/components/SchoolYearFilter';
|
||||||
|
import { getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears } from '@/utils/Date';
|
||||||
|
import { CURRENT_YEAR_FILTER, NEXT_YEAR_FILTER, HISTORICAL_FILTER } from '@/utils/constants';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
function getPeriodString(periodValue, frequency) {
|
function getPeriodString(periodValue, frequency, schoolYear = null) {
|
||||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
const year = schoolYear || (() => {
|
||||||
const schoolYear = `${year}-${year + 1}`;
|
const y = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
if (frequency === 1) return `T${periodValue}_${schoolYear}`;
|
return `${y}-${y + 1}`;
|
||||||
if (frequency === 2) return `S${periodValue}_${schoolYear}`;
|
})();
|
||||||
if (frequency === 3) return `A_${schoolYear}`;
|
if (frequency === 1) return `T${periodValue}_${year}`;
|
||||||
|
if (frequency === 2) return `S${periodValue}_${year}`;
|
||||||
|
if (frequency === 3) return `A_${year}`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcPercent(data) {
|
function calcCompetencyStats(data) {
|
||||||
if (!data?.data) return null;
|
if (!data?.data) return null;
|
||||||
const scores = [];
|
const scores = [];
|
||||||
data.data.forEach((d) =>
|
data.data.forEach((d) =>
|
||||||
d.categories.forEach((c) =>
|
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;
|
if (!scores.length) return null;
|
||||||
return Math.round(
|
const total = scores.length;
|
||||||
(scores.filter((s) => s === 3).length / scores.length) * 100
|
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) {
|
function getPeriodColumns(frequency) {
|
||||||
@ -58,6 +92,29 @@ function getPeriodColumns(frequency) {
|
|||||||
return [];
|
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) {
|
function getCurrentPeriodValue(frequency) {
|
||||||
const periods =
|
const periods =
|
||||||
{
|
{
|
||||||
@ -81,18 +138,19 @@ function getCurrentPeriodValue(frequency) {
|
|||||||
return current?.value ?? null;
|
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 (loading) return <span className="text-gray-300 text-xs">…</span>;
|
||||||
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
||||||
const color =
|
const badgeColor =
|
||||||
value >= 75
|
color ||
|
||||||
|
(value >= 75
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: value >= 50
|
: value >= 50
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-red-100 text-red-600';
|
: 'bg-red-100 text-red-600');
|
||||||
return (
|
return (
|
||||||
<span
|
<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}%
|
{value}%
|
||||||
</span>
|
</span>
|
||||||
@ -101,6 +159,7 @@ function PercentBadge({ value, loading }) {
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
@ -111,17 +170,39 @@ export default function Page() {
|
|||||||
const [statsMap, setStatsMap] = useState({});
|
const [statsMap, setStatsMap] = useState({});
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
const [absencesMap, setAbsencesMap] = useState({});
|
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
|
||||||
);
|
), [selectedEstablishmentEvaluationFrequency]);
|
||||||
|
|
||||||
const currentPeriodValue = getCurrentPeriodValue(
|
const currentPeriodValue = getCurrentPeriodValue(
|
||||||
selectedEstablishmentEvaluationFrequency
|
selectedEstablishmentEvaluationFrequency
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEstablishmentId) return;
|
if (!selectedEstablishmentId) return;
|
||||||
fetchStudents(selectedEstablishmentId, null, 5)
|
fetchStudents(selectedEstablishmentId, null, 5, selectedSchoolYear)
|
||||||
.then((data) => setStudents(data))
|
.then((data) => setStudents(data))
|
||||||
.catch((error) => logger.error('Error fetching students:', error));
|
.catch((error) => logger.error('Error fetching students:', error));
|
||||||
|
|
||||||
@ -136,9 +217,9 @@ export default function Page() {
|
|||||||
setAbsencesMap(map);
|
setAbsencesMap(map);
|
||||||
})
|
})
|
||||||
.catch((error) => logger.error('Error fetching absences:', error));
|
.catch((error) => logger.error('Error fetching absences:', error));
|
||||||
}, [selectedEstablishmentId]);
|
}, [selectedEstablishmentId, selectedSchoolYear]);
|
||||||
|
|
||||||
// Fetch stats for all students × all periods
|
// Fetch stats for all students - aggregate all periods
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
||||||
|
|
||||||
@ -147,7 +228,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const tasks = students.flatMap((student) =>
|
const tasks = students.flatMap((student) =>
|
||||||
periodColumns.map(({ value: periodValue }) => {
|
periodColumns.map(({ value: periodValue }) => {
|
||||||
const periodStr = getPeriodString(periodValue, frequency);
|
const periodStr = getPeriodString(periodValue, frequency, selectedSchoolYear);
|
||||||
return fetchStudentCompetencies(student.id, periodStr)
|
return fetchStudentCompetencies(student.id, periodStr)
|
||||||
.then((data) => ({ studentId: student.id, periodValue, data }))
|
.then((data) => ({ studentId: student.id, periodValue, data }))
|
||||||
.catch(() => ({ studentId: student.id, periodValue, data: null }));
|
.catch(() => ({ studentId: student.id, periodValue, data: null }));
|
||||||
@ -156,20 +237,50 @@ export default function Page() {
|
|||||||
|
|
||||||
Promise.all(tasks).then((results) => {
|
Promise.all(tasks).then((results) => {
|
||||||
const map = {};
|
const map = {};
|
||||||
results.forEach(({ studentId, periodValue, data }) => {
|
// Group by student and aggregate all competency scores across periods
|
||||||
if (!map[studentId]) map[studentId] = {};
|
const studentScores = {};
|
||||||
map[studentId][periodValue] = calcPercent(data);
|
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) => {
|
// Calculate stats for each student
|
||||||
const vals = Object.values(map[id]).filter((v) => v !== null);
|
Object.keys(studentScores).forEach((studentId) => {
|
||||||
map[id].global = vals.length
|
const scores = studentScores[studentId];
|
||||||
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
if (!scores.length) {
|
||||||
: null;
|
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);
|
setStatsMap(map);
|
||||||
setStatsLoading(false);
|
setStatsLoading(false);
|
||||||
});
|
});
|
||||||
}, [students, selectedEstablishmentEvaluationFrequency]);
|
}, [students, selectedEstablishmentEvaluationFrequency, selectedSchoolYear, periodColumns]);
|
||||||
|
|
||||||
const filteredStudents = students.filter(
|
const filteredStudents = students.filter(
|
||||||
(student) =>
|
(student) =>
|
||||||
@ -189,11 +300,135 @@ export default function Page() {
|
|||||||
currentPage * ITEMS_PER_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) => {
|
const handleEvaluer = (e, studentId) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const periodStr = getPeriodString(
|
const periodStr = getPeriodString(
|
||||||
currentPeriodValue,
|
currentPeriodValue,
|
||||||
selectedEstablishmentEvaluationFrequency
|
selectedEstablishmentEvaluationFrequency,
|
||||||
|
selectedSchoolYear
|
||||||
);
|
);
|
||||||
router.push(
|
router.push(
|
||||||
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
|
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
|
||||||
@ -205,8 +440,10 @@ export default function Page() {
|
|||||||
{ name: 'Élève', transform: () => null },
|
{ name: 'Élève', transform: () => null },
|
||||||
{ name: 'Niveau', transform: () => null },
|
{ name: 'Niveau', transform: () => null },
|
||||||
{ name: 'Classe', transform: () => null },
|
{ name: 'Classe', transform: () => null },
|
||||||
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
|
...COMPETENCY_COLUMNS.map(({ label }) => ({
|
||||||
{ name: 'Stat globale', transform: () => null },
|
name: label,
|
||||||
|
transform: () => null,
|
||||||
|
})),
|
||||||
{ name: 'Absences', transform: () => null },
|
{ name: 'Absences', transform: () => null },
|
||||||
{ name: 'Actions', transform: () => null },
|
{ name: 'Actions', transform: () => null },
|
||||||
];
|
];
|
||||||
@ -219,13 +456,13 @@ export default function Page() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{student.photo ? (
|
{student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${student.photo}`}
|
href={getSecureFileUrl(student.photo)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${student.photo}`}
|
src={getSecureFileUrl(student.photo)}
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
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"
|
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">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -252,7 +490,9 @@ export default function Page() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
className="text-emerald-700 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
@ -261,13 +501,6 @@ export default function Page() {
|
|||||||
) : (
|
) : (
|
||||||
student.associated_class_name
|
student.associated_class_name
|
||||||
);
|
);
|
||||||
case 'Stat globale':
|
|
||||||
return (
|
|
||||||
<PercentBadge
|
|
||||||
value={stats.global ?? null}
|
|
||||||
loading={statsLoading && !('global' in stats)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'Absences':
|
case 'Absences':
|
||||||
return absencesMap[student.id] ? (
|
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">
|
<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 (
|
return (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<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"
|
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"
|
title="Voir la fiche"
|
||||||
>
|
>
|
||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
Fiche
|
Fiche
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(e) => handleEvaluer(e, student.id)}
|
onClick={(e) => handleEvaluer(e, student.id)}
|
||||||
disabled={!currentPeriodValue}
|
disabled={!currentPeriodValue}
|
||||||
@ -299,12 +543,13 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default: {
|
default: {
|
||||||
const col = periodColumns.find((c) => c.label === column);
|
const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
|
||||||
if (col) {
|
if (col) {
|
||||||
return (
|
return (
|
||||||
<PercentBadge
|
<PercentBadge
|
||||||
value={stats[col.value] ?? null}
|
value={stats?.[col.key] ?? null}
|
||||||
loading={statsLoading && !(col.value in stats)}
|
loading={statsLoading && !stats}
|
||||||
|
color={col.color}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -320,18 +565,34 @@ export default function Page() {
|
|||||||
title="Suivi pédagogique"
|
title="Suivi pédagogique"
|
||||||
description="Suivez le parcours d'un élève"
|
description="Suivez le parcours d'un élève"
|
||||||
/>
|
/>
|
||||||
<div className="relative flex-grow max-w-md">
|
<SchoolYearFilter
|
||||||
<Search
|
activeFilter={activeYearFilter}
|
||||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
onFilterChange={setActiveYearFilter}
|
||||||
size={20}
|
showNextYear={true}
|
||||||
/>
|
showHistorical={true}
|
||||||
<input
|
/>
|
||||||
type="text"
|
<div className="flex justify-between items-center w-full">
|
||||||
placeholder="Rechercher un élève"
|
<div className="relative flex-grow">
|
||||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
<Search
|
||||||
value={searchTerm}
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
size={20}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher un élève"
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors ml-4"
|
||||||
|
title="Exporter en CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@ -346,6 +607,291 @@ export default function Page() {
|
|||||||
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
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 Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
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 { useSearchParams } from 'next/navigation';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -17,10 +17,12 @@ import {
|
|||||||
editAbsences,
|
editAbsences,
|
||||||
deleteAbsences,
|
deleteAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation';
|
||||||
|
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -38,8 +40,53 @@ export default function Page() {
|
|||||||
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
||||||
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
|
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 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
|
// AbsenceMoment constants
|
||||||
const AbsenceMoment = {
|
const AbsenceMoment = {
|
||||||
@ -158,6 +205,87 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredStudents, fetchedAbsences]);
|
}, [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) => {
|
const handleLevelClick = (label) => {
|
||||||
setSelectedLevels(
|
setSelectedLevels(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
@ -474,48 +602,83 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Affichage de la date du jour */}
|
{/* Tabs Navigation */}
|
||||||
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex border-b border-gray-200">
|
||||||
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
|
<button
|
||||||
<Clock className="w-6 h-6" />
|
onClick={() => setActiveTab('attendance')}
|
||||||
</div>
|
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
|
||||||
<h2 className="text-lg font-semibold text-gray-800">
|
activeTab === 'attendance'
|
||||||
Appel du jour :{' '}
|
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
|
||||||
<span className="ml-2 text-emerald-600">{today}</span>
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
</h2>
|
}`}
|
||||||
</div>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{!isEditingAttendance ? (
|
<Clock className="w-5 h-5" />
|
||||||
<Button
|
Appel du jour
|
||||||
text="Faire l'appel"
|
</div>
|
||||||
onClick={handleToggleAttendanceMode}
|
</button>
|
||||||
primary
|
<button
|
||||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
onClick={() => setActiveTab('evaluations')}
|
||||||
/>
|
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
|
||||||
) : (
|
activeTab === 'evaluations'
|
||||||
<Button
|
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
|
||||||
text="Valider l'appel"
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
onClick={handleValidateAttendance}
|
}`}
|
||||||
primary
|
>
|
||||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
<div className="flex items-center justify-center gap-2">
|
||||||
/>
|
<ClipboardList className="w-5 h-5" />
|
||||||
)}
|
Évaluations
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
{/* Tab Content: Attendance */}
|
||||||
columns={[
|
{activeTab === 'attendance' && (
|
||||||
{
|
<>
|
||||||
name: 'Nom',
|
{/* Affichage de la date du jour */}
|
||||||
transform: (row) => (
|
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
|
||||||
<div className="text-center">{row.last_name}</div>
|
<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>
|
||||||
name: 'Prénom',
|
<h2 className="text-lg font-semibold text-gray-800">
|
||||||
transform: (row) => (
|
Appel du jour :{' '}
|
||||||
<div className="text-center">{row.first_name}</div>
|
<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
|
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 */}
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@ -34,12 +34,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
fetchRegistrationSchoolFileMasters,
|
fetchRegistrationSchoolFileMasters,
|
||||||
fetchRegistrationParentFileMasters
|
fetchRegistrationParentFileMasters,
|
||||||
} from '@/app/actions/registerFileGroupAction';
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
import { fetchProfiles } from '@/app/actions/authAction';
|
import { fetchProfiles } from '@/app/actions/authAction';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
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';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
|
||||||
export default function CreateSubscriptionPage() {
|
export default function CreateSubscriptionPage() {
|
||||||
@ -181,7 +182,9 @@ export default function CreateSubscriptionPage() {
|
|||||||
formDataRef.current = formData;
|
formDataRef.current = formData;
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
useEffect(() => { setStudentsPage(1); }, [students]);
|
useEffect(() => {
|
||||||
|
setStudentsPage(1);
|
||||||
|
}, [students]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formData.guardianEmail) {
|
if (!formData.guardianEmail) {
|
||||||
@ -530,7 +533,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
'Succès'
|
'Succès'
|
||||||
);
|
);
|
||||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
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 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) {
|
if (isLoading === true) {
|
||||||
return <Loader />; // Affichez le composant Loader
|
return <Loader />; // Affichez le composant Loader
|
||||||
@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.photo ? (
|
{row.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.photo}`}
|
src={getSecureFileUrl(row.photo)}
|
||||||
alt={`${row.first_name} ${row.last_name}`}
|
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"
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Eye,
|
Eye,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
@ -36,8 +37,8 @@ import {
|
|||||||
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
||||||
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
||||||
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
||||||
BASE_URL,
|
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
@ -55,6 +56,7 @@ import {
|
|||||||
} from '@/utils/constants';
|
} from '@/utils/constants';
|
||||||
import AlertMessage from '@/components/AlertMessage';
|
import AlertMessage from '@/components/AlertMessage';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import { exportToCSV } from '@/utils/exportCSV';
|
||||||
|
|
||||||
export default function Page({ params: { locale } }) {
|
export default function Page({ params: { locale } }) {
|
||||||
const t = useTranslations('subscriptions');
|
const t = useTranslations('subscriptions');
|
||||||
@ -112,15 +114,29 @@ export default function Page({ params: { locale } }) {
|
|||||||
// Valide le refus
|
// Valide le refus
|
||||||
const handleRefuse = () => {
|
const handleRefuse = () => {
|
||||||
if (!refuseReason.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const formData = new FormData();
|
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)
|
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||||
.then(() => {
|
.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);
|
setReloadFetch(true);
|
||||||
setIsRefusePopupOpen(false);
|
setIsRefusePopupOpen(false);
|
||||||
})
|
})
|
||||||
@ -149,6 +165,49 @@ export default function Page({ params: { locale } }) {
|
|||||||
setIsFilesModalOpen(true);
|
setIsFilesModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export CSV
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const dataToExport = activeTab === CURRENT_YEAR_FILTER
|
||||||
|
? registrationFormsDataCurrentYear
|
||||||
|
: activeTab === NEXT_YEAR_FILTER
|
||||||
|
? registrationFormsDataNextYear
|
||||||
|
: registrationFormsDataHistorical;
|
||||||
|
|
||||||
|
const exportColumns = [
|
||||||
|
{ key: 'student', label: 'Nom', transform: (value) => value?.last_name || '' },
|
||||||
|
{ key: 'student', label: 'Prénom', transform: (value) => value?.first_name || '' },
|
||||||
|
{ key: 'student', label: 'Date de naissance', transform: (value) => value?.birth_date || '' },
|
||||||
|
{ key: 'student', label: 'Email contact', transform: (value) => value?.guardians?.[0]?.associated_profile_email || '' },
|
||||||
|
{ key: 'student', label: 'Téléphone contact', transform: (value) => value?.guardians?.[0]?.phone || '' },
|
||||||
|
{ key: 'student', label: 'Nom responsable 1', transform: (value) => value?.guardians?.[0]?.last_name || '' },
|
||||||
|
{ key: 'student', label: 'Prénom responsable 1', transform: (value) => value?.guardians?.[0]?.first_name || '' },
|
||||||
|
{ key: 'student', label: 'Nom responsable 2', transform: (value) => value?.guardians?.[1]?.last_name || '' },
|
||||||
|
{ key: 'student', label: 'Prénom responsable 2', transform: (value) => value?.guardians?.[1]?.first_name || '' },
|
||||||
|
{ key: 'school_year', label: 'Année scolaire' },
|
||||||
|
{ key: 'status', label: 'Statut', transform: (value) => {
|
||||||
|
const statusMap = {
|
||||||
|
0: 'En attente',
|
||||||
|
1: 'En cours',
|
||||||
|
2: 'Envoyé',
|
||||||
|
3: 'À relancer',
|
||||||
|
4: 'À valider',
|
||||||
|
5: 'Validé',
|
||||||
|
6: 'Archivé',
|
||||||
|
};
|
||||||
|
return statusMap[value] || value;
|
||||||
|
}},
|
||||||
|
{ key: 'formatted_last_update', label: 'Dernière mise à jour' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const yearLabel = activeTab === CURRENT_YEAR_FILTER
|
||||||
|
? currentSchoolYear
|
||||||
|
: activeTab === NEXT_YEAR_FILTER
|
||||||
|
? nextSchoolYear
|
||||||
|
: 'historique';
|
||||||
|
const filename = `inscriptions_${yearLabel}_${new Date().toISOString().split('T')[0]}`;
|
||||||
|
exportToCSV(dataToExport, exportColumns, filename);
|
||||||
|
};
|
||||||
|
|
||||||
const requestErrorHandler = (err) => {
|
const requestErrorHandler = (err) => {
|
||||||
logger.error('Error fetching data:', err);
|
logger.error('Error fetching data:', err);
|
||||||
};
|
};
|
||||||
@ -668,12 +727,12 @@ export default function Page({ params: { locale } }) {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.student.photo ? (
|
{row.student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.student.photo}`}
|
src={getSecureFileUrl(row.student.photo)}
|
||||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
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"
|
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}
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{profileRole !== 0 && (
|
<div className="flex items-center gap-2 ml-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleExportCSV}
|
||||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||||
router.push(url);
|
title="Exporter en CSV"
|
||||||
}}
|
|
||||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
</button>
|
</button>
|
||||||
)}
|
{profileRole !== 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||||
|
router.push(url);
|
||||||
|
}}
|
||||||
|
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@ -898,7 +967,9 @@ export default function Page({ params: { locale } }) {
|
|||||||
isOpen={isRefusePopupOpen}
|
isOpen={isRefusePopupOpen}
|
||||||
message={
|
message={
|
||||||
<div>
|
<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
|
<Textarea
|
||||||
value={refuseReason}
|
value={refuseReason}
|
||||||
onChange={(e) => setRefuseReason(e.target.value)}
|
onChange={(e) => setRefuseReason(e.target.value)}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -139,12 +139,12 @@ export default function ParentHomePage() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.student.photo ? (
|
{row.student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.student.photo}`}
|
src={getSecureFileUrl(row.student.photo)}
|
||||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
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"
|
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" />
|
<Eye className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.sepa_file}`}
|
href={getSecureFileUrl(row.sepa_file)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
|
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_PAYMENT_MODES_URL,
|
||||||
BE_SCHOOL_ESTABLISHMENT_URL,
|
BE_SCHOOL_ESTABLISHMENT_URL,
|
||||||
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
||||||
|
BE_SCHOOL_EVALUATIONS_URL,
|
||||||
|
BE_SCHOOL_STUDENT_EVALUATIONS_URL,
|
||||||
|
BE_SCHOOL_SCHOOL_YEARS_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||||
|
|
||||||
@ -44,10 +47,15 @@ export const fetchTeachers = (establishment) => {
|
|||||||
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
|
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClasses = (establishment) => {
|
export const fetchClasses = (establishment, options = {}) => {
|
||||||
return fetchWithAuth(
|
let url = `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`;
|
||||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
if (options.schoolYear) url += `&school_year=${options.schoolYear}`;
|
||||||
);
|
if (options.yearFilter) url += `&year_filter=${options.yearFilter}`;
|
||||||
|
return fetchWithAuth(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSchoolYears = () => {
|
||||||
|
return fetchWithAuth(BE_SCHOOL_SCHOOL_YEARS_URL);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClasse = (id) => {
|
export const fetchClasse = (id) => {
|
||||||
@ -132,3 +140,71 @@ export const removeDatas = (url, id, csrfToken) => {
|
|||||||
headers: { 'X-CSRFToken': 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);
|
return fetchWithAuth(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchStudents = (establishment, id = null, status = null) => {
|
export const fetchStudents = (establishment, id = null, status = null, schoolYear = null) => {
|
||||||
let url;
|
let url;
|
||||||
if (id) {
|
if (id) {
|
||||||
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
|
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
|
||||||
@ -133,6 +133,9 @@ export const fetchStudents = (establishment, id = null, status = null) => {
|
|||||||
if (status) {
|
if (status) {
|
||||||
url += `&status=${status}`;
|
url += `&status=${status}`;
|
||||||
}
|
}
|
||||||
|
if (schoolYear) {
|
||||||
|
url += `&school_year=${encodeURIComponent(schoolYear)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fetchWithAuth(url);
|
return fetchWithAuth(url);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { Inter, Manrope } from 'next/font/google';
|
||||||
import Providers from '@/components/Providers';
|
import Providers from '@/components/Providers';
|
||||||
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
|
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
|
||||||
import '@/css/tailwind.css';
|
import '@/css/tailwind.css';
|
||||||
import { headers } from 'next/headers';
|
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 = {
|
export const metadata = {
|
||||||
title: 'N3WT-SCHOOL',
|
title: 'N3WT-SCHOOL',
|
||||||
description: "Gestion de l'école",
|
description: "Gestion de l'école",
|
||||||
@ -36,7 +49,7 @@ export default async function RootLayout({ children, params }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<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}>
|
<Providers messages={messages} locale={locale} session={params.session}>
|
||||||
{children}
|
{children}
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
const FileAttachment = ({
|
const FileAttachment = ({
|
||||||
fileName,
|
fileName,
|
||||||
@ -16,6 +17,7 @@ const FileAttachment = ({
|
|||||||
fileUrl,
|
fileUrl,
|
||||||
onDownload = null,
|
onDownload = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const secureUrl = getSecureFileUrl(fileUrl);
|
||||||
// Obtenir l'icône en fonction du type de fichier
|
// Obtenir l'icône en fonction du type de fichier
|
||||||
const getFileIcon = (type) => {
|
const getFileIcon = (type) => {
|
||||||
if (type.startsWith('image/')) {
|
if (type.startsWith('image/')) {
|
||||||
@ -49,9 +51,9 @@ const FileAttachment = ({
|
|||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (onDownload) {
|
if (onDownload) {
|
||||||
onDownload();
|
onDownload();
|
||||||
} else if (fileUrl) {
|
} else if (secureUrl) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = fileUrl;
|
link.href = secureUrl;
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@ -64,14 +66,14 @@ const FileAttachment = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
{isImage && fileUrl ? (
|
{isImage && secureUrl ? (
|
||||||
// Affichage pour les images
|
// Affichage pour les images
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={secureUrl}
|
||||||
alt={fileName}
|
alt={fileName}
|
||||||
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
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">
|
<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
|
<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 React, { useState, useEffect } from 'react';
|
||||||
import FormRenderer from '@/components/Form/FormRenderer';
|
import FormRenderer from '@/components/Form/FormRenderer';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
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 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
|
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||||
@ -36,8 +43,12 @@ export default function DynamicFormsList({
|
|||||||
const dataState = { ...prevData };
|
const dataState = { ...prevData };
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
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é
|
// 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 hasLocalData =
|
||||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
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) {
|
if (!hasLocalData && hasServerData) {
|
||||||
// Pas de données locales mais données serveur : utiliser les données serveur
|
// 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 };
|
const validationState = { ...prevValidation };
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
schoolFileTemplates.forEach((tpl) => {
|
||||||
const hasLocalValidation = prevValidation[tpl.id] === true;
|
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) {
|
if (!hasLocalValidation && hasServerData) {
|
||||||
// Pas validé localement mais données serveur : marquer comme validé
|
// Pas validé localement mais données serveur : marquer comme validé
|
||||||
@ -76,13 +90,21 @@ export default function DynamicFormsList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
// 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(
|
const allFormsValid = schoolFileTemplates.every(
|
||||||
tpl => tpl.isValidated === true ||
|
(tpl) =>
|
||||||
|
tpl.isValidated === true ||
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(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);
|
onValidationChange(allFormsValid);
|
||||||
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
|
}, [
|
||||||
|
formsData,
|
||||||
|
formsValidation,
|
||||||
|
existingResponses,
|
||||||
|
schoolFileTemplates,
|
||||||
|
onValidationChange,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gère la soumission d'un formulaire individuel
|
* Gère la soumission d'un formulaire individuel
|
||||||
@ -177,9 +199,9 @@ export default function DynamicFormsList({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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) =>
|
const isDynamicForm = (template) =>
|
||||||
template.formTemplateData &&
|
template.formTemplateData &&
|
||||||
@ -205,11 +227,15 @@ export default function DynamicFormsList({
|
|||||||
<div className="text-sm text-gray-600 mb-4">
|
<div className="text-sm text-gray-600 mb-4">
|
||||||
{/* Compteur x/y : inclut les documents validés */}
|
{/* Compteur x/y : inclut les documents validés */}
|
||||||
{
|
{
|
||||||
schoolFileTemplates.filter(tpl => {
|
schoolFileTemplates.filter((tpl) => {
|
||||||
// Validé ou complété localement
|
// Validé ou complété localement
|
||||||
return tpl.isValidated === true ||
|
return (
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
tpl.isValidated === true ||
|
||||||
(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)
|
||||||
|
);
|
||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
{' / '}
|
{' / '}
|
||||||
@ -219,11 +245,13 @@ export default function DynamicFormsList({
|
|||||||
{/* Tri des templates par état */}
|
{/* Tri des templates par état */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Helper pour état
|
// Helper pour état
|
||||||
const getState = tpl => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0; // validé
|
if (tpl.isValidated === true) return 0; // validé
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
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
|
if (isCompletedLocally) return 1; // complété/en attente
|
||||||
return 2; // à compléter/refusé
|
return 2; // à compléter/refusé
|
||||||
@ -234,11 +262,17 @@ export default function DynamicFormsList({
|
|||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sortedTemplates.map((tpl, index) => {
|
{sortedTemplates.map((tpl, index) => {
|
||||||
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
const isActive =
|
||||||
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
|
schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
||||||
|
const isValidated =
|
||||||
|
typeof tpl.isValidated === 'boolean'
|
||||||
|
? tpl.isValidated
|
||||||
|
: undefined;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Statut d'affichage
|
// Statut d'affichage
|
||||||
@ -258,8 +292,12 @@ export default function DynamicFormsList({
|
|||||||
borderClass = 'border border-emerald-200';
|
borderClass = 'border border-emerald-200';
|
||||||
textClass = 'text-emerald-700';
|
textClass = 'text-emerald-700';
|
||||||
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
||||||
borderClass = isActive ? 'border border-emerald-300' : borderClass;
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
|
? 'border border-emerald-300'
|
||||||
|
: borderClass;
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-emerald-900 font-semibold'
|
||||||
|
: textClass;
|
||||||
canEdit = false;
|
canEdit = false;
|
||||||
} else if (isValidated === false) {
|
} else if (isValidated === false) {
|
||||||
if (isCompletedLocally) {
|
if (isCompletedLocally) {
|
||||||
@ -267,16 +305,24 @@ export default function DynamicFormsList({
|
|||||||
statusColor = 'orange';
|
statusColor = 'orange';
|
||||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
? 'border border-orange-300'
|
||||||
|
: 'border border-orange-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-orange-900 font-semibold'
|
||||||
|
: 'text-orange-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
} else {
|
} else {
|
||||||
statusLabel = 'Refusé';
|
statusLabel = 'Refusé';
|
||||||
statusColor = 'red';
|
statusColor = 'red';
|
||||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
||||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||||
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
|
? 'border border-red-300'
|
||||||
|
: 'border border-red-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-red-900 font-semibold'
|
||||||
|
: 'text-red-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -285,8 +331,12 @@ export default function DynamicFormsList({
|
|||||||
statusColor = 'orange';
|
statusColor = 'orange';
|
||||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
? 'border border-orange-300'
|
||||||
|
: 'border border-orange-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-orange-900 font-semibold'
|
||||||
|
: 'text-orange-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
} else {
|
} else {
|
||||||
statusLabel = 'À compléter';
|
statusLabel = 'À compléter';
|
||||||
@ -294,7 +344,9 @@ export default function DynamicFormsList({
|
|||||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||||
bgClass = isActive ? 'bg-gray-200' : '';
|
bgClass = isActive ? 'bg-gray-200' : '';
|
||||||
borderClass = isActive ? 'border border-gray-300' : '';
|
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;
|
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.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
||||||
: `${bgClass} ${borderClass} ${textClass}`
|
: `${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>
|
<span className="mr-3">{icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm truncate flex items-center gap-2">
|
<div className="text-sm truncate flex items-center gap-2">
|
||||||
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
|
{tpl.formMasterData?.title ||
|
||||||
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
|
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}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -337,38 +398,56 @@ export default function DynamicFormsList({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-xl font-semibold text-gray-800">
|
<h3 className="text-xl font-semibold text-gray-800">
|
||||||
{currentTemplate.name}
|
{currentTemplate.name}
|
||||||
</h3>
|
</h3>
|
||||||
{/* Label d'état */}
|
{/* Label d'état */}
|
||||||
{currentTemplate.isValidated === true ? (
|
{currentTemplate.isValidated === true ? (
|
||||||
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
||||||
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
Validé
|
||||||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
|
</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</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>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{currentTemplate.formTemplateData?.description ||
|
{currentTemplate.formTemplateData?.description ||
|
||||||
currentTemplate.description || ''}
|
currentTemplate.description ||
|
||||||
|
''}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
Formulaire {(() => {
|
Formulaire{' '}
|
||||||
|
{(() => {
|
||||||
// Trouver l'index du template courant dans la liste triée
|
// Trouver l'index du template courant dans la liste triée
|
||||||
const getState = tpl => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0;
|
if (tpl.isValidated === true) return 0;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
if (isCompletedLocally) return 1;
|
if (isCompletedLocally) return 1;
|
||||||
return 2;
|
return 2;
|
||||||
};
|
};
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
|
const sortedTemplates = [...schoolFileTemplates].sort(
|
||||||
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
|
(a, b) => getState(a) - getState(b)
|
||||||
|
);
|
||||||
|
const idx = sortedTemplates.findIndex(
|
||||||
|
(tpl) => tpl.id === currentTemplate.id
|
||||||
|
);
|
||||||
return idx + 1;
|
return idx + 1;
|
||||||
})()} sur {schoolFileTemplates.length}
|
})()}{' '}
|
||||||
|
sur {schoolFileTemplates.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -405,14 +484,15 @@ export default function DynamicFormsList({
|
|||||||
// Formulaire existant (PDF, image, etc.)
|
// Formulaire existant (PDF, image, etc.)
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
{/* Cas validé : affichage en iframe */}
|
{/* Cas validé : affichage en iframe */}
|
||||||
{currentTemplate.isValidated === true && currentTemplate.file && (
|
{currentTemplate.isValidated === true &&
|
||||||
<iframe
|
currentTemplate.file && (
|
||||||
src={`${BASE_URL}${currentTemplate.file}`}
|
<iframe
|
||||||
title={currentTemplate.name}
|
src={getSecureFileUrl(currentTemplate.file)}
|
||||||
className="w-full"
|
title={currentTemplate.name}
|
||||||
style={{ height: '600px', border: 'none' }}
|
className="w-full"
|
||||||
/>
|
style={{ height: '600px', border: 'none' }}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cas non validé : bouton télécharger + upload */}
|
{/* Cas non validé : bouton télécharger + upload */}
|
||||||
{currentTemplate.isValidated !== true && (
|
{currentTemplate.isValidated !== true && (
|
||||||
@ -420,9 +500,7 @@ export default function DynamicFormsList({
|
|||||||
{/* Bouton télécharger le document source */}
|
{/* Bouton télécharger le document source */}
|
||||||
{currentTemplate.file && (
|
{currentTemplate.file && (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${currentTemplate.file}`}
|
href={getSecureFileUrl(currentTemplate.file)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
@ -436,7 +514,9 @@ export default function DynamicFormsList({
|
|||||||
<FileUpload
|
<FileUpload
|
||||||
key={currentTemplate.id}
|
key={currentTemplate.id}
|
||||||
selectionMessage={'Sélectionnez le fichier du document'}
|
selectionMessage={'Sélectionnez le fichier du document'}
|
||||||
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
onFileSelect={(file) =>
|
||||||
|
handleUpload(file, currentTemplate)
|
||||||
|
}
|
||||||
required
|
required
|
||||||
enable={true}
|
enable={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
const FilesModal = ({
|
const FilesModal = ({
|
||||||
@ -56,27 +56,27 @@ const FilesModal = ({
|
|||||||
registrationFile: selectedRegisterForm.registration_file
|
registrationFile: selectedRegisterForm.registration_file
|
||||||
? {
|
? {
|
||||||
name: 'Fiche élève',
|
name: 'Fiche élève',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.registration_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.registration_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
fusionFile: selectedRegisterForm.fusion_file
|
fusionFile: selectedRegisterForm.fusion_file
|
||||||
? {
|
? {
|
||||||
name: 'Documents fusionnés',
|
name: 'Documents fusionnés',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.fusion_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
schoolFiles: fetchedSchoolFiles.map((file) => ({
|
schoolFiles: fetchedSchoolFiles.map((file) => ({
|
||||||
name: file.name || 'Document scolaire',
|
name: file.name || 'Document scolaire',
|
||||||
url: file.file ? `${BASE_URL}${file.file}` : null,
|
url: file.file ? getSecureFileUrl(file.file) : null,
|
||||||
})),
|
})),
|
||||||
parentFiles: parentFiles.map((file) => ({
|
parentFiles: parentFiles.map((file) => ({
|
||||||
name: file.master_name || 'Document parent',
|
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
|
sepaFile: selectedRegisterForm.sepa_file
|
||||||
? {
|
? {
|
||||||
name: 'Mandat SEPA',
|
name: 'Mandat SEPA',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.sepa_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
|
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 Popup from '@/components/Popup';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
@ -230,7 +230,7 @@ export default function FilesToUpload({
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{actionType === 'view' && selectedFile.fileName ? (
|
{actionType === 'view' && selectedFile.fileName ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={`${BASE_URL}${selectedFile.fileName}`}
|
src={getSecureFileUrl(selectedFile.fileName)}
|
||||||
title="Document Viewer"
|
title="Document Viewer"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import logger from '@/utils/logger';
|
|||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import { levels, genders } from '@/utils/constants';
|
import { levels, genders } from '@/utils/constants';
|
||||||
|
|
||||||
export default function StudentInfoForm({
|
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
|
// Convertir la photo en fichier binaire si elle est un chemin ou une URL
|
||||||
if (photoPath && typeof photoPath === 'string') {
|
if (photoPath && typeof photoPath === 'string') {
|
||||||
fetch(`${BASE_URL}${photoPath}`)
|
fetch(getSecureFileUrl(photoPath))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de la récupération de la photo.');
|
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 Popup from '@/components/Popup';
|
||||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||||
import SelectChoice from '@/components/Form/SelectChoice';
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
|
||||||
import {
|
import {
|
||||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { School, FileText } from 'lucide-react';
|
import { School, FileText } from 'lucide-react';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
@ -49,15 +49,18 @@ export default function ValidateSubscription({
|
|||||||
// Parent templates
|
// Parent templates
|
||||||
parentFileTemplates.forEach((tpl, i) => {
|
parentFileTemplates.forEach((tpl, i) => {
|
||||||
if (typeof tpl.isValidated === 'boolean') {
|
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]);
|
}, [schoolFileTemplates, parentFileTemplates]);
|
||||||
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
|
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
|
||||||
|
|
||||||
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
|
// 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({
|
const [formData, setFormData] = useState({
|
||||||
associated_class: null,
|
associated_class: null,
|
||||||
@ -131,7 +134,7 @@ export default function ValidateSubscription({
|
|||||||
const handleRefuseDossier = () => {
|
const handleRefuseDossier = () => {
|
||||||
// Message clair avec la liste des documents refusés
|
// Message clair avec la liste des documents refusés
|
||||||
let notes = 'Dossier non validé pour les raisons suivantes :\n';
|
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 = {
|
const data = {
|
||||||
status: 2,
|
status: 2,
|
||||||
notes,
|
notes,
|
||||||
@ -177,10 +180,18 @@ export default function ValidateSubscription({
|
|||||||
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
||||||
|
|
||||||
// Récupère la liste des documents à cocher (hors fiche élève)
|
// Récupère la liste des documents à cocher (hors fiche élève)
|
||||||
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
|
const docIndexes = allTemplates
|
||||||
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
|
.map((_, idx) => idx)
|
||||||
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
|
.filter((idx) => idx !== 0);
|
||||||
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
|
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);
|
logger.debug(allTemplates);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -202,7 +213,7 @@ export default function ValidateSubscription({
|
|||||||
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
||||||
</h3>
|
</h3>
|
||||||
<iframe
|
<iframe
|
||||||
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`}
|
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
|
||||||
title={
|
title={
|
||||||
allTemplates[currentTemplateIndex].type === 'main'
|
allTemplates[currentTemplateIndex].type === 'main'
|
||||||
? 'Document Principal'
|
? '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
|
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'}`}
|
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
|
||||||
aria-pressed={docStatuses[index] === 'accepted'}
|
aria-pressed={docStatuses[index] === 'accepted'}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
|
setDocStatuses((s) => ({
|
||||||
|
...s,
|
||||||
|
[index]: 'accepted',
|
||||||
|
}));
|
||||||
// Appel API pour valider le document
|
// Appel API pour valider le document
|
||||||
if (handleValidateOrRefuseDoc) {
|
if (handleValidateOrRefuseDoc) {
|
||||||
let template = null;
|
let template = null;
|
||||||
let type = null;
|
let type = null;
|
||||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
if (
|
||||||
|
index > 0 &&
|
||||||
|
index <= schoolFileTemplates.length
|
||||||
|
) {
|
||||||
template = schoolFileTemplates[index - 1];
|
template = schoolFileTemplates[index - 1];
|
||||||
type = 'school';
|
type = 'school';
|
||||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
} else if (
|
||||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
index > schoolFileTemplates.length &&
|
||||||
|
index <=
|
||||||
|
schoolFileTemplates.length +
|
||||||
|
parentFileTemplates.length
|
||||||
|
) {
|
||||||
|
template =
|
||||||
|
parentFileTemplates[
|
||||||
|
index - 1 - schoolFileTemplates.length
|
||||||
|
];
|
||||||
type = 'parent';
|
type = 'parent';
|
||||||
}
|
}
|
||||||
if (template && template.id) {
|
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
|
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'}`}
|
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
|
||||||
aria-pressed={docStatuses[index] === 'refused'}
|
aria-pressed={docStatuses[index] === 'refused'}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
|
setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
|
||||||
// Appel API pour refuser le document
|
// Appel API pour refuser le document
|
||||||
if (handleValidateOrRefuseDoc) {
|
if (handleValidateOrRefuseDoc) {
|
||||||
let template = null;
|
let template = null;
|
||||||
let type = null;
|
let type = null;
|
||||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
if (
|
||||||
|
index > 0 &&
|
||||||
|
index <= schoolFileTemplates.length
|
||||||
|
) {
|
||||||
template = schoolFileTemplates[index - 1];
|
template = schoolFileTemplates[index - 1];
|
||||||
type = 'school';
|
type = 'school';
|
||||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
} else if (
|
||||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
index > schoolFileTemplates.length &&
|
||||||
|
index <=
|
||||||
|
schoolFileTemplates.length +
|
||||||
|
parentFileTemplates.length
|
||||||
|
) {
|
||||||
|
template =
|
||||||
|
parentFileTemplates[
|
||||||
|
index - 1 - schoolFileTemplates.length
|
||||||
|
];
|
||||||
type = 'parent';
|
type = 'parent';
|
||||||
}
|
}
|
||||||
if (template && template.id) {
|
if (template && template.id) {
|
||||||
@ -351,7 +387,7 @@ export default function ValidateSubscription({
|
|||||||
<div className="mt-auto py-4">
|
<div className="mt-auto py-4">
|
||||||
<Button
|
<Button
|
||||||
text="Soumettre"
|
text="Soumettre"
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
// 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
|
// 2. Si tous cochés et au moins un refusé : popup refus
|
||||||
@ -367,12 +403,14 @@ export default function ValidateSubscription({
|
|||||||
}}
|
}}
|
||||||
primary
|
primary
|
||||||
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
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-gray-300 text-gray-700 cursor-not-allowed'
|
||||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
}`}
|
}`}
|
||||||
disabled={
|
disabled={
|
||||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
!allChecked ||
|
||||||
|
(allChecked && allValidated && !formData.associated_class)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -391,7 +429,7 @@ export default function ValidateSubscription({
|
|||||||
<span className="font-semibold text-blue-700">{email}</span>
|
<span className="font-semibold text-blue-700">{email}</span>
|
||||||
{' avec la liste des documents non validés :'}
|
{' avec la liste des documents non validés :'}
|
||||||
<ul className="list-disc ml-6 mt-2">
|
<ul className="list-disc ml-6 mt-2">
|
||||||
{refusedDocs.map(doc => (
|
{refusedDocs.map((doc) => (
|
||||||
<li key={doc.idx}>{doc.name}</li>
|
<li key={doc.idx}>{doc.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
|
|||||||
import { getRightStr } from '@/utils/rights';
|
import { getRightStr } from '@/utils/rights';
|
||||||
import { ChevronDown } from 'lucide-react'; // Import de l'icône
|
import { ChevronDown } from 'lucide-react'; // Import de l'icône
|
||||||
import Image from 'next/image'; // Import du composant Image
|
import Image from 'next/image'; // Import du composant Image
|
||||||
import {
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
BASE_URL,
|
|
||||||
} from '@/utils/Url';
|
|
||||||
|
|
||||||
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
||||||
const {
|
const {
|
||||||
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
setSelectedEstablishmentEvaluationFrequency,
|
setSelectedEstablishmentEvaluationFrequency,
|
||||||
setSelectedEstablishmentTotalCapacity,
|
setSelectedEstablishmentTotalCapacity,
|
||||||
selectedEstablishmentLogo,
|
selectedEstablishmentLogo,
|
||||||
setSelectedEstablishmentLogo
|
setSelectedEstablishmentLogo,
|
||||||
} = useEstablishment();
|
} = useEstablishment();
|
||||||
const { isConnected, connectionStatus } = useChatConnection();
|
const { isConnected, connectionStatus } = useChatConnection();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
user.roles[roleId].establishment__evaluation_frequency;
|
user.roles[roleId].establishment__evaluation_frequency;
|
||||||
const establishmentTotalCapacity =
|
const establishmentTotalCapacity =
|
||||||
user.roles[roleId].establishment__total_capacity;
|
user.roles[roleId].establishment__total_capacity;
|
||||||
const establishmentLogo =
|
const establishmentLogo = user.roles[roleId].establishment__logo;
|
||||||
user.roles[roleId].establishment__logo;
|
|
||||||
setProfileRole(role);
|
setProfileRole(role);
|
||||||
setSelectedEstablishmentId(establishmentId);
|
setSelectedEstablishmentId(establishmentId);
|
||||||
setSelectedEstablishmentEvaluationFrequency(
|
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="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
src={
|
||||||
|
selectedEstablishmentLogo
|
||||||
|
? getSecureFileUrl(selectedEstablishmentLogo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-8 h-8 rounded-full object-cover shadow-md"
|
className="w-8 h-8 rounded-full object-cover shadow-md"
|
||||||
width={32}
|
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="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
src={
|
||||||
|
selectedEstablishmentLogo
|
||||||
|
? getSecureFileUrl(selectedEstablishmentLogo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-16 h-16 rounded-full object-cover shadow-md"
|
className="w-16 h-16 rounded-full object-cover shadow-md"
|
||||||
width={64}
|
width={64}
|
||||||
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
label: (
|
label: (
|
||||||
<div className="flex items-center text-left">
|
<div className="flex items-center text-left">
|
||||||
<Image
|
<Image
|
||||||
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)}
|
src={
|
||||||
|
establishment.logo
|
||||||
|
? getSecureFileUrl(establishment.logo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
|
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div>
|
<div className="font-bold ext-sm text-gray-500">
|
||||||
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div>
|
{establishment.name}
|
||||||
|
</div>
|
||||||
|
<div className="italic text-sm text-gray-500">
|
||||||
|
{getRightStr(establishment.role_type)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -212,9 +225,10 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
buttonClassName="w-full"
|
buttonClassName="w-full"
|
||||||
menuClassName={compact
|
menuClassName={
|
||||||
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
|
compact
|
||||||
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
|
? '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}
|
dropdownOpen={dropdownOpen}
|
||||||
setDropdownOpen={setDropdownOpen}
|
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,
|
button = false,
|
||||||
buttonOpeningModal = false,
|
buttonOpeningModal = false,
|
||||||
onClick = null,
|
onClick = null,
|
||||||
|
secondaryButton = null, // Bouton secondaire (ex: export)
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@ -29,18 +30,21 @@ const SectionHeader = ({
|
|||||||
<p className="text-sm text-gray-500 italic">{description}</p>
|
<p className="text-sm text-gray-500 italic">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{button && onClick && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{secondaryButton}
|
||||||
onClick={onClick}
|
{button && onClick && (
|
||||||
className={
|
<button
|
||||||
buttonOpeningModal
|
onClick={onClick}
|
||||||
? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300'
|
className={
|
||||||
: 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2'
|
buttonOpeningModal
|
||||||
}
|
? 'flex items-center bg-emerald-200 text-emerald-700 p-2 rounded-full shadow-sm hover:bg-emerald-300'
|
||||||
>
|
: 'text-emerald-500 hover:bg-emerald-200 rounded-full p-2'
|
||||||
<Plus className="w-6 h-6" />
|
}
|
||||||
</button>
|
>
|
||||||
)}
|
<Plus className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react';
|
import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand, Download } from 'lucide-react';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import InputText from '@/components/Form/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
@ -17,6 +17,8 @@ import { usePlanning } from '@/context/PlanningContext';
|
|||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import AlertMessage from '@/components/AlertMessage';
|
import AlertMessage from '@/components/AlertMessage';
|
||||||
|
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
||||||
|
import { exportToCSV } from '@/utils/exportCSV';
|
||||||
|
|
||||||
const ItemTypes = {
|
const ItemTypes = {
|
||||||
TEACHER: 'teacher',
|
TEACHER: 'teacher',
|
||||||
@ -115,6 +117,7 @@ const TeachersDropZone = ({
|
|||||||
|
|
||||||
const ClassesSection = ({
|
const ClassesSection = ({
|
||||||
classes,
|
classes,
|
||||||
|
allClasses,
|
||||||
setClasses,
|
setClasses,
|
||||||
teachers,
|
teachers,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
@ -132,13 +135,15 @@ const ClassesSection = ({
|
|||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
const ITEMS_PER_PAGE = 10;
|
const ITEMS_PER_PAGE = 10;
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// Les classes arrivent déjà filtrées depuis le parent
|
||||||
useEffect(() => { setCurrentPage(1); }, [classes]);
|
useEffect(() => { setCurrentPage(1); }, [classes]);
|
||||||
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
|
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
|
||||||
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
|
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
|
||||||
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
||||||
const { getNiveauxLabels, allNiveaux } = useClasses();
|
const { getNiveauxLabels, getNiveauLabel, allNiveaux } = useClasses();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Fonction pour générer les années scolaires
|
// Fonction pour générer les années scolaires
|
||||||
@ -188,6 +193,30 @@ const ClassesSection = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export CSV
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const exportColumns = [
|
||||||
|
{ key: 'id', label: 'ID' },
|
||||||
|
{ key: 'atmosphere_name', label: "Nom d'ambiance" },
|
||||||
|
{ key: 'age_range', label: "Tranche d'âge" },
|
||||||
|
{
|
||||||
|
key: 'levels',
|
||||||
|
label: 'Niveaux',
|
||||||
|
transform: (value) => value ? value.map(l => getNiveauLabel(l)).join(', ') : ''
|
||||||
|
},
|
||||||
|
{ key: 'number_of_students', label: 'Capacité max' },
|
||||||
|
{ key: 'school_year', label: 'Année scolaire' },
|
||||||
|
{
|
||||||
|
key: 'teachers_details',
|
||||||
|
label: 'Enseignants',
|
||||||
|
transform: (value) => value ? value.map(t => `${t.last_name} ${t.first_name}`).join(', ') : ''
|
||||||
|
},
|
||||||
|
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||||
|
];
|
||||||
|
const filename = `classes_${new Date().toISOString().split('T')[0]}`;
|
||||||
|
exportToCSV(classes, exportColumns, filename);
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
|
||||||
@ -559,6 +588,16 @@ const ClassesSection = ({
|
|||||||
description="Gérez les classes de votre école"
|
description="Gérez les classes de votre école"
|
||||||
button={profileRole !== 0}
|
button={profileRole !== 0}
|
||||||
onClick={handleAddClass}
|
onClick={handleAddClass}
|
||||||
|
secondaryButton={
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||||
|
title="Exporter en CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
|
data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
|
import { Trash2, Edit3, Check, X, BookOpen, Download } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import AlertMessage from '@/components/AlertMessage';
|
import AlertMessage from '@/components/AlertMessage';
|
||||||
|
import { exportToCSV } from '@/utils/exportCSV';
|
||||||
|
|
||||||
const SpecialitiesSection = ({
|
const SpecialitiesSection = ({
|
||||||
specialities,
|
specialities,
|
||||||
|
allSpecialities,
|
||||||
setSpecialities,
|
setSpecialities,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
@ -36,6 +40,21 @@ const SpecialitiesSection = ({
|
|||||||
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
|
const { selectedSchoolYear } = useSchoolYearFilter();
|
||||||
|
|
||||||
|
// Fonction pour générer les années scolaires
|
||||||
|
const getSchoolYearChoices = () => {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const currentYear = currentDate.getFullYear();
|
||||||
|
const currentMonth = currentDate.getMonth() + 1;
|
||||||
|
const startYear = currentMonth >= 9 ? currentYear : currentYear - 1;
|
||||||
|
const choices = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const year = startYear + i;
|
||||||
|
choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` });
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
};
|
||||||
|
|
||||||
// Récupération des messages d'erreur
|
// Récupération des messages d'erreur
|
||||||
const getError = (field) => {
|
const getError = (field) => {
|
||||||
@ -43,7 +62,20 @@ const SpecialitiesSection = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSpeciality = () => {
|
const handleAddSpeciality = () => {
|
||||||
setNewSpeciality({ id: Date.now(), name: '', color_code: '' });
|
setNewSpeciality({ id: Date.now(), name: '', color_code: '', school_year: selectedSchoolYear });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export CSV
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const exportColumns = [
|
||||||
|
{ key: 'id', label: 'ID' },
|
||||||
|
{ key: 'name', label: 'Nom' },
|
||||||
|
{ key: 'color_code', label: 'Code couleur' },
|
||||||
|
{ key: 'school_year', label: 'Année scolaire' },
|
||||||
|
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||||
|
];
|
||||||
|
const filename = `specialites_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`;
|
||||||
|
exportToCSV(specialities, exportColumns, filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSpeciality = (id) => {
|
const handleRemoveSpeciality = (id) => {
|
||||||
@ -150,6 +182,20 @@ const SpecialitiesSection = ({
|
|||||||
errorMsg={getError('name')}
|
errorMsg={getError('name')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'ANNÉE SCOLAIRE':
|
||||||
|
return (
|
||||||
|
<SelectChoice
|
||||||
|
type="select"
|
||||||
|
name="school_year"
|
||||||
|
placeHolder="Sélectionnez une année scolaire"
|
||||||
|
choices={getSchoolYearChoices()}
|
||||||
|
callback={handleChange}
|
||||||
|
selected={currentData.school_year || ''}
|
||||||
|
errorMsg={getError('school_year')}
|
||||||
|
IconItem={null}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'ACTIONS':
|
case 'ACTIONS':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="flex justify-center space-x-2">
|
||||||
@ -184,6 +230,8 @@ const SpecialitiesSection = ({
|
|||||||
switch (column) {
|
switch (column) {
|
||||||
case 'LIBELLE':
|
case 'LIBELLE':
|
||||||
return <SpecialityItem key={speciality.id} speciality={speciality} />;
|
return <SpecialityItem key={speciality.id} speciality={speciality} />;
|
||||||
|
case 'ANNÉE SCOLAIRE':
|
||||||
|
return speciality.school_year;
|
||||||
case 'MISE A JOUR':
|
case 'MISE A JOUR':
|
||||||
return speciality.updated_date_formatted;
|
return speciality.updated_date_formatted;
|
||||||
case 'ACTIONS':
|
case 'ACTIONS':
|
||||||
@ -244,6 +292,7 @@ const SpecialitiesSection = ({
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ name: 'LIBELLE', label: 'Libellé' },
|
{ name: 'LIBELLE', label: 'Libellé' },
|
||||||
|
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
|
||||||
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
|
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
|
||||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||||
];
|
];
|
||||||
@ -257,6 +306,16 @@ const SpecialitiesSection = ({
|
|||||||
description="Gérez les spécialités de votre école"
|
description="Gérez les spécialités de votre école"
|
||||||
button={profileRole !== 0}
|
button={profileRole !== 0}
|
||||||
onClick={handleAddSpeciality}
|
onClick={handleAddSpeciality}
|
||||||
|
secondaryButton={
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||||
|
title="Exporter en CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
|
data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
|
||||||
|
|||||||
@ -1,14 +1,126 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection';
|
import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection';
|
||||||
import TeachersSection from '@/components/Structure/Configuration/TeachersSection';
|
import TeachersSection from '@/components/Structure/Configuration/TeachersSection';
|
||||||
import ClassesSection from '@/components/Structure/Configuration/ClassesSection';
|
import ClassesSection from '@/components/Structure/Configuration/ClassesSection';
|
||||||
import { ClassesProvider } from '@/context/ClassesContext';
|
import { ClassesProvider } from '@/context/ClassesContext';
|
||||||
|
import { SchoolYearFilterProvider, useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||||
|
import SchoolYearFilter from '@/components/SchoolYearFilter';
|
||||||
import {
|
import {
|
||||||
BE_SCHOOL_SPECIALITIES_URL,
|
BE_SCHOOL_SPECIALITIES_URL,
|
||||||
BE_SCHOOL_TEACHERS_URL,
|
BE_SCHOOL_TEACHERS_URL,
|
||||||
BE_SCHOOL_SCHOOLCLASSES_URL,
|
BE_SCHOOL_SCHOOLCLASSES_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
|
||||||
|
const StructureManagementContent = ({
|
||||||
|
specialities,
|
||||||
|
setSpecialities,
|
||||||
|
teachers,
|
||||||
|
setTeachers,
|
||||||
|
classes,
|
||||||
|
setClasses,
|
||||||
|
profiles,
|
||||||
|
handleCreate,
|
||||||
|
handleEdit,
|
||||||
|
handleDelete,
|
||||||
|
}) => {
|
||||||
|
const { activeYearFilter, setActiveYearFilter, filterByYear } = useSchoolYearFilter();
|
||||||
|
|
||||||
|
// Filtrer les données par année scolaire
|
||||||
|
const filteredSpecialities = useMemo(() => filterByYear(specialities), [specialities, filterByYear]);
|
||||||
|
const filteredTeachers = useMemo(() => filterByYear(teachers), [teachers, filterByYear]);
|
||||||
|
const filteredClasses = useMemo(() => filterByYear(classes), [classes, filterByYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<SchoolYearFilter
|
||||||
|
activeFilter={activeYearFilter}
|
||||||
|
onFilterChange={setActiveYearFilter}
|
||||||
|
showNextYear={true}
|
||||||
|
showHistorical={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
|
||||||
|
<div className="mt-8 flex flex-col xl:flex-row gap-8">
|
||||||
|
<div className="w-full xl:w-2/5">
|
||||||
|
<SpecialitiesSection
|
||||||
|
specialities={filteredSpecialities}
|
||||||
|
allSpecialities={specialities}
|
||||||
|
setSpecialities={setSpecialities}
|
||||||
|
handleCreate={(newData) =>
|
||||||
|
handleCreate(
|
||||||
|
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||||
|
newData,
|
||||||
|
setSpecialities
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleEdit={(id, updatedData) =>
|
||||||
|
handleEdit(
|
||||||
|
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
||||||
|
id,
|
||||||
|
updatedData,
|
||||||
|
setSpecialities
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleDelete={(id) =>
|
||||||
|
handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full xl:flex-1">
|
||||||
|
<TeachersSection
|
||||||
|
teachers={filteredTeachers}
|
||||||
|
allTeachers={teachers}
|
||||||
|
setTeachers={setTeachers}
|
||||||
|
specialities={filteredSpecialities}
|
||||||
|
profiles={profiles}
|
||||||
|
handleCreate={(newData) =>
|
||||||
|
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
|
||||||
|
}
|
||||||
|
handleEdit={(id, updatedData) =>
|
||||||
|
handleEdit(
|
||||||
|
`${BE_SCHOOL_TEACHERS_URL}`,
|
||||||
|
id,
|
||||||
|
updatedData,
|
||||||
|
setTeachers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleDelete={(id) =>
|
||||||
|
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full mt-8 xl:mt-12">
|
||||||
|
<ClassesSection
|
||||||
|
classes={filteredClasses}
|
||||||
|
allClasses={classes}
|
||||||
|
setClasses={setClasses}
|
||||||
|
teachers={filteredTeachers}
|
||||||
|
handleCreate={(newData) =>
|
||||||
|
handleCreate(
|
||||||
|
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||||
|
newData,
|
||||||
|
setClasses
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleEdit={(id, updatedData) =>
|
||||||
|
handleEdit(
|
||||||
|
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
||||||
|
id,
|
||||||
|
updatedData,
|
||||||
|
setClasses
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleDelete={(id) =>
|
||||||
|
handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const StructureManagement = ({
|
const StructureManagement = ({
|
||||||
specialities,
|
specialities,
|
||||||
setSpecialities,
|
setSpecialities,
|
||||||
@ -23,82 +135,22 @@ const StructureManagement = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ClassesProvider>
|
<SchoolYearFilterProvider>
|
||||||
{/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
|
<ClassesProvider>
|
||||||
<div className="mt-8 flex flex-col xl:flex-row gap-8">
|
<StructureManagementContent
|
||||||
<div className="w-full xl:w-2/5">
|
specialities={specialities}
|
||||||
<SpecialitiesSection
|
setSpecialities={setSpecialities}
|
||||||
specialities={specialities}
|
teachers={teachers}
|
||||||
setSpecialities={setSpecialities}
|
setTeachers={setTeachers}
|
||||||
handleCreate={(newData) =>
|
|
||||||
handleCreate(
|
|
||||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
|
||||||
newData,
|
|
||||||
setSpecialities
|
|
||||||
)
|
|
||||||
}
|
|
||||||
handleEdit={(id, updatedData) =>
|
|
||||||
handleEdit(
|
|
||||||
`${BE_SCHOOL_SPECIALITIES_URL}`,
|
|
||||||
id,
|
|
||||||
updatedData,
|
|
||||||
setSpecialities
|
|
||||||
)
|
|
||||||
}
|
|
||||||
handleDelete={(id) =>
|
|
||||||
handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full xl:flex-1">
|
|
||||||
<TeachersSection
|
|
||||||
teachers={teachers}
|
|
||||||
setTeachers={setTeachers}
|
|
||||||
specialities={specialities}
|
|
||||||
profiles={profiles}
|
|
||||||
handleCreate={(newData) =>
|
|
||||||
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
|
|
||||||
}
|
|
||||||
handleEdit={(id, updatedData) =>
|
|
||||||
handleEdit(
|
|
||||||
`${BE_SCHOOL_TEACHERS_URL}`,
|
|
||||||
id,
|
|
||||||
updatedData,
|
|
||||||
setTeachers
|
|
||||||
)
|
|
||||||
}
|
|
||||||
handleDelete={(id) =>
|
|
||||||
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full mt-8 xl:mt-12">
|
|
||||||
<ClassesSection
|
|
||||||
classes={classes}
|
classes={classes}
|
||||||
setClasses={setClasses}
|
setClasses={setClasses}
|
||||||
teachers={teachers}
|
profiles={profiles}
|
||||||
handleCreate={(newData) =>
|
handleCreate={handleCreate}
|
||||||
handleCreate(
|
handleEdit={handleEdit}
|
||||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
handleDelete={handleDelete}
|
||||||
newData,
|
|
||||||
setClasses
|
|
||||||
)
|
|
||||||
}
|
|
||||||
handleEdit={(id, updatedData) =>
|
|
||||||
handleEdit(
|
|
||||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}`,
|
|
||||||
id,
|
|
||||||
updatedData,
|
|
||||||
setClasses
|
|
||||||
)
|
|
||||||
}
|
|
||||||
handleDelete={(id) =>
|
|
||||||
handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</ClassesProvider>
|
||||||
</ClassesProvider>
|
</SchoolYearFilterProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
|
import { Edit3, Trash2, GraduationCap, Check, X, Hand, Download } from 'lucide-react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import { DndProvider, useDrop } from 'react-dnd';
|
import { DndProvider, useDrop } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
import InputText from '@/components/Form/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
@ -10,8 +11,10 @@ import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'
|
|||||||
import TeacherItem from './TeacherItem';
|
import TeacherItem from './TeacherItem';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useSchoolYearFilter } from '@/context/SchoolYearFilterContext';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import AlertMessage from '@/components/AlertMessage';
|
import AlertMessage from '@/components/AlertMessage';
|
||||||
|
import { exportToCSV } from '@/utils/exportCSV';
|
||||||
|
|
||||||
const ItemTypes = {
|
const ItemTypes = {
|
||||||
SPECIALITY: 'speciality',
|
SPECIALITY: 'speciality',
|
||||||
@ -120,6 +123,7 @@ const SpecialitiesDropZone = ({
|
|||||||
|
|
||||||
const TeachersSection = ({
|
const TeachersSection = ({
|
||||||
teachers,
|
teachers,
|
||||||
|
allTeachers,
|
||||||
setTeachers,
|
setTeachers,
|
||||||
specialities,
|
specialities,
|
||||||
profiles,
|
profiles,
|
||||||
@ -145,6 +149,22 @@ const TeachersSection = ({
|
|||||||
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
|
const { selectedSchoolYear } = useSchoolYearFilter();
|
||||||
|
|
||||||
|
// Génère les choix d'année scolaire (année en cours + 2 suivantes)
|
||||||
|
const getSchoolYearChoices = () => {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const currentYear = currentDate.getFullYear();
|
||||||
|
const currentMonth = currentDate.getMonth() + 1;
|
||||||
|
// Si on est avant septembre, l'année scolaire a commencé l'année précédente
|
||||||
|
const startYear = currentMonth >= 9 ? currentYear : currentYear - 1;
|
||||||
|
const choices = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const year = startYear + i;
|
||||||
|
choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` });
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
};
|
||||||
|
|
||||||
// --- UTILS ---
|
// --- UTILS ---
|
||||||
|
|
||||||
@ -197,6 +217,7 @@ const TeachersSection = ({
|
|||||||
associated_profile_email: '',
|
associated_profile_email: '',
|
||||||
specialities: [],
|
specialities: [],
|
||||||
role_type: 0,
|
role_type: 0,
|
||||||
|
school_year: selectedSchoolYear,
|
||||||
});
|
});
|
||||||
setFormData({
|
setFormData({
|
||||||
last_name: '',
|
last_name: '',
|
||||||
@ -204,9 +225,34 @@ const TeachersSection = ({
|
|||||||
associated_profile_email: '',
|
associated_profile_email: '',
|
||||||
specialities: [],
|
specialities: [],
|
||||||
role_type: 0,
|
role_type: 0,
|
||||||
|
school_year: selectedSchoolYear,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export CSV
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const exportColumns = [
|
||||||
|
{ key: 'id', label: 'ID' },
|
||||||
|
{ key: 'last_name', label: 'Nom' },
|
||||||
|
{ key: 'first_name', label: 'Prénom' },
|
||||||
|
{ key: 'associated_profile_email', label: 'Email' },
|
||||||
|
{
|
||||||
|
key: 'specialities_details',
|
||||||
|
label: 'Spécialités',
|
||||||
|
transform: (value) => value ? value.map(s => s.name).join(', ') : ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role_type',
|
||||||
|
label: 'Profil',
|
||||||
|
transform: (value) => value === 1 ? 'Administrateur' : 'Enseignant'
|
||||||
|
},
|
||||||
|
{ key: 'school_year', label: 'Année scolaire' },
|
||||||
|
{ key: 'updated_date_formatted', label: 'Date de mise à jour' },
|
||||||
|
];
|
||||||
|
const filename = `enseignants_${selectedSchoolYear || 'toutes'}_${new Date().toISOString().split('T')[0]}`;
|
||||||
|
exportToCSV(teachers, exportColumns, filename);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveTeacher = (id) => {
|
const handleRemoveTeacher = (id) => {
|
||||||
logger.debug('[DELETE] Suppression teacher id:', id);
|
logger.debug('[DELETE] Suppression teacher id:', id);
|
||||||
return handleDelete(id)
|
return handleDelete(id)
|
||||||
@ -242,6 +288,7 @@ const TeachersSection = ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
school_year: formData.school_year || selectedSchoolYear,
|
||||||
specialities: formData.specialities || [],
|
specialities: formData.specialities || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -293,6 +340,7 @@ const TeachersSection = ({
|
|||||||
handleEdit(id, {
|
handleEdit(id, {
|
||||||
last_name: updatedData.last_name,
|
last_name: updatedData.last_name,
|
||||||
first_name: updatedData.first_name,
|
first_name: updatedData.first_name,
|
||||||
|
school_year: updatedData.school_year || selectedSchoolYear,
|
||||||
profile_role_data: profileRoleData,
|
profile_role_data: profileRoleData,
|
||||||
specialities: updatedData.specialities || [],
|
specialities: updatedData.specialities || [],
|
||||||
})
|
})
|
||||||
@ -391,6 +439,20 @@ const TeachersSection = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'ANNÉE SCOLAIRE':
|
||||||
|
return (
|
||||||
|
<SelectChoice
|
||||||
|
type="select"
|
||||||
|
name="school_year"
|
||||||
|
placeHolder="Sélectionnez une année scolaire"
|
||||||
|
choices={getSchoolYearChoices()}
|
||||||
|
callback={handleChange}
|
||||||
|
selected={currentData.school_year || ''}
|
||||||
|
errorMsg={getError('school_year')}
|
||||||
|
IconItem={null}
|
||||||
|
disabled={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'ACTIONS':
|
case 'ACTIONS':
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="flex justify-center space-x-2">
|
||||||
@ -459,6 +521,8 @@ const TeachersSection = ({
|
|||||||
} else {
|
} else {
|
||||||
return <i>Non définie</i>;
|
return <i>Non définie</i>;
|
||||||
}
|
}
|
||||||
|
case 'ANNÉE SCOLAIRE':
|
||||||
|
return teacher.school_year;
|
||||||
case 'MISE A JOUR':
|
case 'MISE A JOUR':
|
||||||
return teacher.updated_date_formatted;
|
return teacher.updated_date_formatted;
|
||||||
case 'ACTIONS':
|
case 'ACTIONS':
|
||||||
@ -526,6 +590,7 @@ const TeachersSection = ({
|
|||||||
{ name: 'EMAIL', label: 'Email' },
|
{ name: 'EMAIL', label: 'Email' },
|
||||||
{ name: 'SPECIALITES', label: 'Spécialités' },
|
{ name: 'SPECIALITES', label: 'Spécialités' },
|
||||||
{ name: 'ADMINISTRATEUR', label: 'Profil' },
|
{ name: 'ADMINISTRATEUR', label: 'Profil' },
|
||||||
|
{ name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' },
|
||||||
{ name: 'MISE A JOUR', label: 'Mise à jour' },
|
{ name: 'MISE A JOUR', label: 'Mise à jour' },
|
||||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||||
];
|
];
|
||||||
@ -539,6 +604,16 @@ const TeachersSection = ({
|
|||||||
description="Gérez les enseignants.es de votre école"
|
description="Gérez les enseignants.es de votre école"
|
||||||
button={profileRole !== 0}
|
button={profileRole !== 0}
|
||||||
onClick={handleAddTeacher}
|
onClick={handleAddTeacher}
|
||||||
|
secondaryButton={
|
||||||
|
<button
|
||||||
|
onClick={handleExportCSV}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-100 rounded-lg hover:bg-emerald-200 transition-colors"
|
||||||
|
title="Exporter en CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Exporter
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
|
data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
|
||||||
|
|||||||
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_PLANS_URL = `${BASE_URL}/School/paymentPlans`;
|
||||||
export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`;
|
export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`;
|
||||||
export const BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`;
|
export const BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`;
|
||||||
|
export const BE_SCHOOL_EVALUATIONS_URL = `${BASE_URL}/School/evaluations`;
|
||||||
|
export const BE_SCHOOL_STUDENT_EVALUATIONS_URL = `${BASE_URL}/School/studentEvaluations`;
|
||||||
|
export const BE_SCHOOL_SCHOOL_YEARS_URL = `${BASE_URL}/School/schoolYears`;
|
||||||
|
|
||||||
// ESTABLISHMENT
|
// ESTABLISHMENT
|
||||||
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;
|
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;
|
||||||
|
|||||||
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
|
'./src/**/*.{js,jsx,ts,tsx}', // Ajustez ce chemin selon la structure de votre projet
|
||||||
],
|
],
|
||||||
theme: {
|
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')],
|
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",
|
"prepare": "husky",
|
||||||
"release": "standard-version",
|
"release": "standard-version",
|
||||||
"update-version": "node scripts/update-version.js",
|
"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": {
|
"standard-version": {
|
||||||
"scripts": {
|
"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