13 Commits

Author SHA1 Message Date
6fb3c5cdb4 feat: lister uniquement les élèves inscrits dans une classe [N3WTS-6] 2026-03-14 13:11:30 +01:00
2fef6d61a4 feat: Gestion du refus définitif d'un dossier [N3WTS-2] 2026-03-14 11:35:19 +01:00
0501c1dd73 feat: Finalisation de la validation / refus des documents signés par les parents [N3WTS-2] 2026-03-14 11:26:20 +01:00
4f7d7d0024 feat: Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] 2026-02-22 18:34:00 +01:00
8fd1b62ec0 feat: Validation document par document [N3WTS-2] 2026-02-19 18:53:33 +01:00
3779a47417 feat: Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] 2026-02-16 17:54:20 +01:00
05c68ebfaa feat: Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] 2026-02-15 18:40:14 +01:00
195579e217 feat: Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] 2026-02-15 18:08:07 +01:00
ddcaba382e feat: Gestion de la sidebar [N3WTS-8] 2026-02-15 18:02:57 +01:00
a82483f3bd chore: Suppression code mort [N3WTS-8] 2026-02-15 17:57:48 +01:00
26d4b5633f fix: Changement des niveaux de logs [N3WTS-1] 2026-02-15 17:39:19 +01:00
d66db1b019 feat: Envoi mail d'inscription au second responsable [N3WTS-1] 2026-02-15 17:36:55 +01:00
bd7dc2b0c2 feat: Envoi mail d'inscription aux enseignants [N3WTS-1] 2026-02-15 16:37:43 +01:00
29 changed files with 1533 additions and 444 deletions

View File

@ -25,7 +25,7 @@ class ProfileRole(models.Model):
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
is_active = models.BooleanField(default=False)
is_active = models.BooleanField(default=False, blank=True)
updated_date = models.DateTimeField(auto_now=True)
def __str__(self):

View File

@ -1,4 +1,4 @@
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
@ -208,3 +208,123 @@ def isValid(message, fiche_inscription):
mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
def sendRegisterTeacher(recipients, establishment_id):
errorMessage = ''
try:
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'email': recipients,
'establishment': establishment_id
}
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/inscription_teacher.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage
def sendRefusDossier(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que son dossier d'inscription
nécessite des corrections.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus (contenu du champ notes du RegistrationForm)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription - Corrections requises'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_SUBJECT
html_message = render_to_string('emails/refus_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus: {errorMessage}")
return errorMessage
def sendValidationDossier(recipients, establishment_id, student_name, class_name=None):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été validé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
class_name: Nom de la classe attribuée (optionnel)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_VALIDATION_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription validé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'class_name': class_name
}
connection = getConnection(establishment_id)
subject = EMAIL_VALIDATION_SUBJECT
html_message = render_to_string('emails/validation_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de validation envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de validation: {errorMessage}")
return errorMessage
def sendRefusDefinitif(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été définitivement refusé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_DEFINITIF_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription refusé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_DEFINITIF_SUBJECT
html_message = render_to_string('emails/refus_definitif.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus définitif envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus définitif: {errorMessage}")
return errorMessage

View File

@ -182,12 +182,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
teachers_details = serializers.SerializerMethodField()
students = StudentDetailSerializer(many=True, read_only=True)
students = serializers.SerializerMethodField()
class Meta:
model = SchoolClass
fields = '__all__'
def get_students(self, obj):
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
validated_students = obj.students.filter(registrationform__status=5)
return StudentDetailSerializer(validated_students, many=True).data
def create(self, validated_data):
teachers_data = validated_data.pop('teachers', [])
levels_data = validated_data.pop('levels', [])

View File

@ -35,6 +35,7 @@ from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency
from Subscriptions.util import getCurrentSchoolYear
import logging
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
logger = logging.getLogger(__name__)
@ -102,8 +103,17 @@ class TeacherListCreateView(APIView):
teacher_serializer = TeacherSerializer(data=teacher_data)
if teacher_serializer.is_valid():
teacher_serializer.save()
teacher_instance = teacher_serializer.save()
# Envoi du mail d'inscription enseignant uniquement à la création
email = None
establishment_id = None
if hasattr(teacher_instance, "profile_role") and teacher_instance.profile_role:
if hasattr(teacher_instance.profile_role, "profile") and teacher_instance.profile_role.profile:
email = teacher_instance.profile_role.profile.email
if hasattr(teacher_instance.profile_role, "establishment") and teacher_instance.profile_role.establishment:
establishment_id = teacher_instance.profile_role.establishment.id
if email and establishment_id:
sendRegisterTeacher(email, establishment_id)
return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False)

View File

@ -214,9 +214,28 @@ class RegistrationFileGroup(models.Model):
def __str__(self):
return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
def registration_form_file_upload_to(instance, filename):
"""
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
student_last = instance.student.last_name if instance.student else "unknown"
student_first = instance.student.first_name if instance.student else "unknown"
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
def _delete_file_if_exists(file_field):
"""
Supprime le fichier physique s'il existe.
Utile pour éviter les suffixes automatiques Django lors du remplacement.
"""
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"Fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
class RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices):
@ -238,17 +257,17 @@ class RegistrationForm(models.Model):
notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
sepa_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
fusion_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
@ -285,13 +304,23 @@ class RegistrationForm(models.Model):
except RegistrationForm.DoesNotExist:
old_fileGroup = None
# Vérifier si un fichier existant doit être remplacé
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
if self.pk: # Si l'objet existe déjà dans la base de données
try:
old_instance = RegistrationForm.objects.get(pk=self.pk)
# Gestion du sepa_file
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier
old_instance.sepa_file.delete(save=False)
_delete_file_if_exists(old_instance.sepa_file)
# Gestion du registration_file
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
_delete_file_if_exists(old_instance.registration_file)
# Gestion du fusion_file
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
_delete_file_if_exists(old_instance.fusion_file)
except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer
@ -485,10 +514,18 @@ class RegistrationParentFileMaster(models.Model):
############################################################
def registration_school_file_upload_to(instance, filename):
"""
Génère le chemin pour les fichiers templates école.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
def registration_parent_file_upload_to(instance, filename):
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
"""
Génère le chemin pour les fichiers à fournir par les parents.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model):
@ -498,19 +535,36 @@ class RegistrationSchoolFileTemplate(models.Model):
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
if old_instance.file and old_instance.file != self.file:
_delete_file_if_exists(old_instance.file)
except RegistrationSchoolFileTemplate.DoesNotExist:
pass
super().save(*args, **kwargs)
@staticmethod
def get_files_from_rf(register_form_id):
"""
Récupère tous les fichiers liés à un dossier dinscription donné.
Ignore les fichiers qui n'existent pas physiquement.
"""
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = []
for reg_file in registration_files:
if reg_file.file and hasattr(reg_file.file, 'path'):
if os.path.exists(reg_file.file.path):
filenames.append(reg_file.file.path)
else:
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
return filenames
class StudentCompetency(models.Model):
@ -540,22 +594,24 @@ class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name
return self.master.name if self.master else f"ParentFile_{self.pk}"
def save(self, *args, **kwargs):
if self.pk: # Si l'objet existe déjà dans la base de données
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''):
if os.path.exists(old_instance.file.path):
old_instance.file.delete(save=False)
# Si le fichier change ou est supprimé
if old_instance.file:
if old_instance.file != self.file or not self.file or self.file.name == '':
_delete_file_if_exists(old_instance.file)
if not self.file or self.file.name == '':
self.file = None
else:
print(f"Le fichier {old_instance.file.path} n'existe pas.")
except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.")
pass
super().save(*args, **kwargs)
@staticmethod

View File

@ -21,6 +21,7 @@ from N3wtSchool import settings
from django.utils import timezone
import pytz
import Subscriptions.util as util
from N3wtSchool.mailManager import sendRegisterForm
class AbsenceManagementSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField()
@ -215,6 +216,14 @@ class StudentSerializer(serializers.ModelSerializer):
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
profile_role_serializer.is_valid(raise_exception=True)
profile_role = profile_role_serializer.save()
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
email = None
if profile_data and 'email' in profile_data:
email = profile_data['email']
elif profile_role and profile_role.profile:
email = profile_role.profile.email
if email:
sendRegisterForm(email, establishment_id)
elif profile_role:
# Récupérer un ProfileRole existant par son ID
profile_role = ProfileRole.objects.get(id=profile_role.id)

View File

@ -0,0 +1,63 @@
<!-- Nouveau template pour l'inscription d'un enseignant -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bienvenue sur N3wt School</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
.logo {
width: 120px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Utilisation d'un lien absolu pour le logo -->
<img src="{{URL_DJANGO}}/static/img/logo_min.svg" alt="Logo N3wt School" class="logo" style="display:block;margin:auto;" />
<h1>Bienvenue sur N3wt School</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Votre compte enseignant a été créé sur la plateforme N3wt School.</p>
<p>Pour accéder à votre espace personnel, veuillez vous connecter à l'adresse suivante :<br>
<a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a>
</p>
<p>Votre identifiant est : <b>{{ email }}</b></p>
<p>Si c'est votre première connexion, veuillez activer votre compte ici :<br>
<a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a>
</p>
<p>Nous vous souhaitons une excellente prise en main de l'outil.<br>
L'équipe N3wt School reste à votre disposition pour toute question.</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription refusé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription refusé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription - Corrections requises</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription - Corrections requises</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription validé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.success-box {
background-color: #d4edda;
border: 1px solid #28a745;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
text-align: center;
}
.success-box h2 {
color: #155724;
margin: 0;
}
.class-info {
background-color: #e7f3ff;
border: 1px solid #0066cc;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription validé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<div class="success-box">
<h2>Félicitations !</h2>
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
</div>
{% if class_name %}
<div class="class-info">
<strong>Classe attribuée :</strong> {{ class_name }}
</div>
{% endif %}
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -19,7 +19,8 @@ from enum import Enum
import random
import string
from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger
from PyPDF2 import PdfMerger, PdfReader
from PyPDF2.errors import PdfReadError
import shutil
import logging
@ -31,6 +32,29 @@ from rest_framework import status
logger = logging.getLogger(__name__)
def save_file_replacing_existing(file_field, filename, content, save=True):
"""
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
Args:
file_field: Le FileField Django (ex: registerForm.registration_file)
filename: Le nom du fichier à sauvegarder
content: Le contenu du fichier (File, BytesIO, ContentFile, etc.)
save: Si True, sauvegarde l'instance parente
"""
# Supprimer l'ancien fichier s'il existe
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
# Sauvegarder le nouveau fichier
file_field.save(filename, content, save=save)
def build_payload_from_request(request):
"""
Normalise la request en payload prêt à être donné au serializer.
@ -344,12 +368,70 @@ def getArgFromRequest(_argument, _request):
def merge_files_pdf(file_paths):
"""
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
Les fichiers invalides sont ignorés avec un log d'erreur.
"""
merger = PdfMerger()
files_added = 0
# Extensions d'images supportées
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
# Ajouter les fichiers valides au merger
for file_path in file_paths:
# Vérifier que le fichier existe
if not os.path.exists(file_path):
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
continue
file_ext = os.path.splitext(file_path)[1].lower()
# Si c'est une image, la convertir en PDF
if file_ext in image_extensions:
try:
from PIL import Image
from reportlab.lib.utils import ImageReader
img = Image.open(file_path)
img_width, img_height = img.size
# Créer un PDF en mémoire avec l'image
img_pdf = BytesIO()
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
c.save()
img_pdf.seek(0)
merger.append(img_pdf)
files_added += 1
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
continue
# Sinon, essayer de l'ajouter comme PDF
try:
# Valider que c'est un PDF lisible
with open(file_path, 'rb') as f:
PdfReader(f, strict=False)
# Si la validation passe, ajouter au merger
merger.append(file_path)
files_added += 1
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
except PdfReadError as e:
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
if files_added == 0:
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
# Retourner un PDF vide
empty_pdf = BytesIO()
c = canvas.Canvas(empty_pdf, pagesize=A4)
c.drawString(100, 750, "Aucun document à afficher")
c.save()
empty_pdf.seek(0)
return empty_pdf
# Sauvegarder le fichier fusionné en mémoire
merged_pdf = BytesIO()
@ -378,25 +460,11 @@ def rfToPDF(registerForm, filename):
if not pdf:
raise ValueError("Erreur lors de la génération du PDF.")
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
if registerForm.registration_file and registerForm.registration_file.name:
# Vérifiez si le chemin est déjà absolu ou relatif
if os.path.isabs(registerForm.registration_file.name):
existing_file_path = registerForm.registration_file.name
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
# Vérifier si le fichier existe et le supprimer
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
registerForm.registration_file.delete(save=False)
else:
print(f'File does not exist: {existing_file_path}')
# Enregistrer directement le fichier dans le champ registration_file
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
try:
registerForm.registration_file.save(
os.path.basename(filename), # Utiliser uniquement le nom de fichier
save_file_replacing_existing(
registerForm.registration_file,
os.path.basename(filename),
File(BytesIO(pdf.content)),
save=True
)

View File

@ -9,6 +9,7 @@ from drf_yasg import openapi
import json
import os
import threading
from django.core.files import File
import N3wtSchool.mailManager as mailer
@ -323,6 +324,27 @@ class RegisterFormWithIdView(APIView):
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
registerForm.save()
# Envoi du mail d'inscription au second guardian si besoin
guardians = registerForm.student.guardians.all()
from Auth.models import Profile
from N3wtSchool.mailManager import sendRegisterForm
for guardian in guardians:
# Recherche de l'email dans le profil lié au guardian (si existant)
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
# Fallback sur le champ email direct (si jamais il existe)
if not email:
email = getattr(guardian, "email", None)
logger.debug(f"[RF_UNDER_REVIEW] Guardian id={guardian.id}, email={email}")
if email:
profile_exists = Profile.objects.filter(email=email).exists()
logger.debug(f"[RF_UNDER_REVIEW] Profile existe pour {email} ? {profile_exists}")
if not profile_exists:
logger.debug(f"[RF_UNDER_REVIEW] Envoi du mail d'inscription à {email} pour l'établissement {registerForm.establishment.pk}")
sendRegisterForm(email, registerForm.establishment.pk)
# Mise à jour de l'automate
# Vérification de la présence du fichier SEPA
if registerForm.sepa_file:
@ -332,9 +354,32 @@ class RegisterFormWithIdView(APIView):
# Mise à jour de l'automate pour une signature classique
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
except Exception as e:
logger.error(f"[RF_UNDER_REVIEW] Exception: {e}")
import traceback
logger.error(traceback.format_exc())
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus aux responsables légaux
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = registerForm.notes or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
updateStateMachine(registerForm, 'EVENT_REFUSE')
util.delete_registration_files(registerForm)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
@ -377,14 +422,23 @@ class RegisterFormWithIdView(APIView):
fileNames.extend(parent_file_templates)
# Création du fichier PDF fusionné
merged_pdf_content = None
try:
merged_pdf_content = util.merge_files_pdf(fileNames)
# Mise à jour du champ registration_file avec le fichier fusionné
registerForm.fusion_file.save(
f"dossier_complet.pdf",
# Mise à jour du champ fusion_file avec le fichier fusionné
util.save_file_replacing_existing(
registerForm.fusion_file,
"dossier_complet.pdf",
File(merged_pdf_content),
save=True
)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
finally:
# Libérer explicitement la mémoire du BytesIO
if merged_pdf_content is not None:
merged_pdf_content.close()
# Valorisation des StudentCompetency pour l'élève
try:
student = registerForm.student
@ -426,8 +480,65 @@ class RegisterFormWithIdView(APIView):
except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
def send_validation_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
class_name = None
if student.associated_class:
class_name = student.associated_class.atmosphere_name
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_validation_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_VALIDATE')
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
def send_refus_definitif_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = data.get('notes', '') or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_refus_definitif_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
# Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False)

View File

@ -11,7 +11,6 @@ import {
Award,
Calendar,
Settings,
LogOut,
MessageSquare,
} from 'lucide-react';
@ -29,16 +28,14 @@ import {
import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute';
import { getGravatarUrl } from '@/utils/gravatar';
import Footer from '@/components/Footer';
import { getRightStr, RIGHTS } from '@/utils/rights';
import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext';
import ProfileSelector from '@/components/ProfileSelector';
export default function Layout({ children }) {
const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, user, clearContext } =
const { profileRole, establishments, clearContext } =
useEstablishment();
const sidebarItems = {
@ -97,45 +94,15 @@ export default function Layout({ children }) {
const pathname = usePathname();
const currentPage = pathname.split('/').pop();
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
const handleDisconnect = () => {
setIsPopupVisible(true);
};
const confirmDisconnect = () => {
setIsPopupVisible(false);
disconnect();
clearContext();
};
const dropdownItems = [
{
type: 'info',
content: (
<div className="px-4 py-2">
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
<div className="text-xs text-gray-400">
{getRightStr(profileRole) || ''}
</div>
</div>
),
},
{
type: 'separator',
content: <hr className="my-2 border-gray-200" />,
},
{
type: 'item',
label: 'Déconnexion',
onClick: handleDisconnect,
icon: LogOut,
},
];
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
@ -145,6 +112,15 @@ export default function Layout({ children }) {
setIsSidebarOpen(false);
}, [pathname]);
// Filtrage dynamique des items de la sidebar selon le rôle
let sidebarItemsToDisplay = Object.values(sidebarItems);
if (profileRole === 0) {
// Si pas admin, on retire "directory" et "settings"
sidebarItemsToDisplay = sidebarItemsToDisplay.filter(
(item) => item.id !== 'directory' && item.id !== 'settings'
);
}
return (
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Sidebar */}
@ -156,7 +132,7 @@ export default function Layout({ children }) {
<Sidebar
establishments={establishments}
currentPage={currentPage}
items={Object.values(sidebarItems)}
items={sidebarItemsToDisplay}
onCloseMobile={toggleSidebar}
/>
</div>

View File

@ -52,7 +52,7 @@ export default function Page() {
);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
useEffect(() => {
if (selectedEstablishmentId) {
@ -316,6 +316,8 @@ export default function Page() {
</div>
),
},
...(profileRole !== 0
? [
{
id: 'Fees',
label: 'Tarifs',
@ -345,6 +347,8 @@ export default function Page() {
</div>
),
},
]
: []),
{
id: 'Files',
label: 'Documents',
@ -353,6 +357,7 @@ export default function Page() {
<FilesGroupsManagement
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
profileRole={profileRole}
/>
</div>
),

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react';
import Table from '@/components/Table';
import Tab from '@/components/Tab';
import Textarea from '@/components/Textarea';
import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel';
import Popup from '@/components/Popup';
@ -17,6 +18,7 @@ import {
Plus,
Upload,
Eye,
XCircle,
} from 'lucide-react';
import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext';
@ -83,12 +85,9 @@ export default function Page({ params: { locale } }) {
const [totalHistorical, setTotalHistorical] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
const [student, setStudent] = useState('');
const [classes, setClasses] = useState([]);
const [reloadFetch, setReloadFetch] = useState(false);
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
@ -99,9 +98,40 @@ export default function Page({ params: { locale } }) {
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
// Refus popup state
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
const [refuseReason, setRefuseReason] = useState('');
const [rowToRefuse, setRowToRefuse] = useState(null);
// Ouvre la popup de refus
const openRefusePopup = (row) => {
setRowToRefuse(row);
setRefuseReason('');
setIsRefusePopupOpen(true);
};
// Valide le refus
const handleRefuse = () => {
if (!refuseReason.trim()) {
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
return;
}
const formData = new FormData();
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
.then(() => {
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
setReloadFetch(true);
setIsRefusePopupOpen(false);
})
.catch(() => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
});
};
const csrfToken = useCsrfToken();
const router = useRouter();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
const { showNotification } = useNotification();
const openSepaUploadModal = (row) => {
@ -490,10 +520,18 @@ export default function Page({ params: { locale } }) {
</span>
),
onClick: () => {
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
router.push(`${url}`);
},
},
{
icon: (
<span title="Refuser le dossier">
<XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
</span>
),
onClick: () => openRefusePopup(row),
},
],
// Etat "A relancer" - NON TESTE
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
@ -801,6 +839,7 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange}
/>
</div>
{profileRole !== 0 && (
<button
onClick={() => {
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
@ -810,6 +849,7 @@ export default function Page({ params: { locale } }) {
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
<div className="w-full">
@ -853,6 +893,25 @@ export default function Page({ params: { locale } }) {
onCancel={() => setConfirmPopupVisible(false)}
/>
{/* Popup de refus de dossier */}
<Popup
isOpen={isRefusePopupOpen}
message={
<div>
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
<Textarea
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}
placeholder="Ex : Réception de dossier trop tardive"
rows={3}
className="w-full"
/>
</div>
}
onConfirm={handleRefuse}
onCancel={() => setIsRefusePopupOpen(false)}
/>
{isSepaUploadModalOpen && (
<Modal
isOpen={isSepaUploadModalOpen}

View File

@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext';
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
export default function Page() {
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
const searchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
@ -20,6 +22,7 @@ export default function Page() {
const studentId = searchParams.get('studentId');
const firstName = searchParams.get('firstName');
const lastName = searchParams.get('lastName');
const email = searchParams.get('email');
const level = searchParams.get('level');
const sepa_file =
searchParams.get('sepa_file') === 'null'
@ -84,6 +87,45 @@ export default function Page() {
});
};
const handleRefuseRF = (data) => {
const formData = new FormData();
formData.append('data', JSON.stringify(data));
editRegisterForm(studentId, formData, csrfToken)
.then((response) => {
logger.debug('RF refusé et archivé:', response);
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
setIsLoadingRefuse(false);
})
.catch((error) => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
setIsLoadingRefuse(false);
logger.error('Erreur lors du refus du RF:', error);
});
};
// Validation/refus d'un document individuel (hors fiche élève)
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
if (!templateId) return;
let editFn = null;
if (type === 'school') {
editFn = editRegistrationSchoolFileTemplates;
} else if (type === 'parent') {
editFn = editRegistrationParentFileTemplates;
}
if (!editFn) return;
const updateData = new FormData();
updateData.append('data', JSON.stringify({ isValidated: validated }));
editFn(templateId, updateData, csrfToken)
.then((response) => {
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
})
.catch((error) => {
logger.error('Erreur lors de la validation/refus du document:', error);
});
};
if (isLoading) {
return <Loader />;
}
@ -93,10 +135,15 @@ export default function Page() {
studentId={studentId}
firstName={firstName}
lastName={lastName}
email={email}
sepa_file={sepa_file}
student_file={student_file}
onAccept={handleAcceptRF}
classes={classes}
onRefuse={handleRefuseRF}
isLoadingRefuse={isLoadingRefuse}
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
csrfToken={csrfToken}
/>
);
}

View File

@ -10,10 +10,16 @@ export default function FileUpload({
required,
errorMsg,
enable = true, // Nouvelle prop pour activer/désactiver le champ
key,
}) {
const [localFileName, setLocalFileName] = useState(uploadedFileName || '');
const fileInputRef = useRef(null);
// Réinitialise localFileName à chaque changement de key (id template)
React.useEffect(() => {
setLocalFileName(uploadedFileName || '');
}, [key, uploadedFileName]);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {

View File

@ -1,5 +1,6 @@
import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react';
@ -28,6 +29,7 @@ export function getIcon(name) {
export default function FormRenderer({
formConfig,
csrfToken,
initialValues = {},
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel)
@ -37,7 +39,14 @@ export default function FormRenderer({
control,
formState: { errors },
reset,
} = useForm();
} = useForm({ defaultValues: initialValues });
// Réinitialiser le formulaire quand les valeurs initiales changent
useEffect(() => {
if (initialValues && Object.keys(initialValues).length > 0) {
reset(initialValues);
}
}, [initialValues, reset]);
// Fonction utilitaire pour envoyer les données au backend
const sendFormDataToBackend = async (formData) => {

View File

@ -2,20 +2,20 @@
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload } from 'lucide-react';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
* @param {Array} schoolFileTemplates - Liste des templates de formulaires
* @param {Object} existingResponses - Réponses déjà sauvegardées
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
* @param {Boolean} enable - Si les formulaires sont modifiables
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
*/
export default function DynamicFormsList({
schoolFileMasters,
schoolFileTemplates,
existingResponses = {},
onFormSubmit,
enable = true,
@ -29,41 +29,60 @@ export default function DynamicFormsList({
// Initialiser les données avec les réponses existantes
useEffect(() => {
if (existingResponses && Object.keys(existingResponses).length > 0) {
setFormsData(existingResponses);
// Initialisation complète de formsValidation et formsData pour chaque template
if (schoolFileTemplates && schoolFileTemplates.length > 0) {
// Fusionner avec l'état existant pour préserver les données locales
setFormsData((prevData) => {
const dataState = { ...prevData };
schoolFileTemplates.forEach((tpl) => {
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
// Marquer les formulaires avec réponses comme valides
const validationState = {};
Object.keys(existingResponses).forEach((formId) => {
if (
existingResponses[formId] &&
Object.keys(existingResponses[formId]).length > 0
) {
validationState[formId] = true;
if (!hasLocalData && hasServerData) {
// Pas de données locales mais données serveur : utiliser les données serveur
dataState[tpl.id] = existingResponses[tpl.id];
} else if (!hasLocalData && !hasServerData) {
// Pas de données du tout : initialiser à vide
dataState[tpl.id] = {};
}
// Si hasLocalData : on garde les données locales existantes
});
return dataState;
});
setFormsValidation(validationState);
}
}, [existingResponses]);
// Debug: Log des formulaires maîtres reçus
useEffect(() => {
logger.debug(
'DynamicFormsList - Formulaires maîtres reçus:',
schoolFileMasters
);
}, [schoolFileMasters]);
// Fusionner avec l'état de validation existant
setFormsValidation((prevValidation) => {
const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => {
const hasLocalValidation = prevValidation[tpl.id] === true;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé
validationState[tpl.id] = true;
} else if (validationState[tpl.id] === undefined) {
// Pas encore initialisé : initialiser à false
validationState[tpl.id] = false;
}
// Si hasLocalValidation : on garde l'état local existant
});
return validationState;
});
}
}, [existingResponses, schoolFileTemplates]);
// Mettre à jour la validation globale quand la validation des formulaires change
useEffect(() => {
const allFormsValid = schoolFileMasters.every(
(master, index) => formsValidation[master.id] === true
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (onValidationChange) {
onValidationChange(allFormsValid);
}
}, [formsValidation, schoolFileMasters, onValidationChange]);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
/**
* Gère la soumission d'un formulaire individuel
@ -90,7 +109,7 @@ export default function DynamicFormsList({
}
// Passer au formulaire suivant si disponible
if (currentTemplateIndex < schoolFileMasters.length - 1) {
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
setCurrentTemplateIndex(currentTemplateIndex + 1);
}
@ -100,16 +119,6 @@ export default function DynamicFormsList({
}
};
/**
* Gère les changements de validation d'un formulaire
*/
const handleFormValidationChange = (isValid, templateId) => {
setFormsValidation((prev) => ({
...prev,
[templateId]: isValid,
}));
};
/**
* Vérifie si un formulaire est complété
*/
@ -140,31 +149,44 @@ export default function DynamicFormsList({
* Obtient le formulaire actuel à afficher
*/
const getCurrentTemplate = () => {
return schoolFileMasters[currentTemplateIndex];
return schoolFileTemplates[currentTemplateIndex];
};
const currentTemplate = getCurrentTemplate();
// Handler d'upload pour formulaire existant
const handleUpload = async (file, selectedFile) => {
if (!file || !selectedFile) return;
try {
const templateId = currentTemplate.id;
if (onFileUpload) {
await onFileUpload(file, selectedFile);
setFormsValidation((prev) => ({
setFormsData((prev) => {
const newData = {
...prev,
[selectedFile.id]: true,
}));
[templateId]: { uploaded: true, fileName: file.name },
};
return newData;
});
setFormsValidation((prev) => {
const newValidation = {
...prev,
[templateId]: true,
};
return newValidation;
});
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
}
};
};
const isDynamicForm = (template) =>
template.formTemplateData &&
Array.isArray(template.formTemplateData.fields) &&
template.formTemplateData.fields.length > 0;
if (!schoolFileMasters || schoolFileMasters.length === 0) {
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
return (
<div className="text-center py-8">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
@ -173,8 +195,6 @@ export default function DynamicFormsList({
);
}
const currentTemplate = getCurrentTemplate();
return (
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
{/* Liste des formulaires */}
@ -183,85 +203,172 @@ export default function DynamicFormsList({
Formulaires à compléter
</h3>
<div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{
Object.keys(formsValidation).filter((id) => formsValidation[id])
.length
}{' '}
/ {schoolFileMasters.length} complétés
schoolFileTemplates.filter(tpl => {
// Validé ou complété localement
return tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
}).length
}
{' / '}
{schoolFileTemplates.length} complétés
</div>
{/* Tri des templates par état */}
{(() => {
// Helper pour état
const getState = tpl => {
if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
return getState(a) - getState(b);
});
return (
<ul className="space-y-2">
{schoolFileMasters.map((master, index) => {
const isActive = index === currentTemplateIndex;
const isCompleted = isFormCompleted(master.id);
{sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
// Statut d'affichage
let statusLabel = '';
let statusColor = '';
let icon = null;
let bgClass = '';
let borderClass = '';
let textClass = '';
let canEdit = true;
if (isValidated === true) {
statusLabel = 'Validé';
statusColor = 'emerald';
icon = <CheckCircle className="w-5 h-5 text-emerald-600" />;
bgClass = 'bg-emerald-50';
borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass;
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
statusLabel = 'Complété';
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'Refusé';
statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
canEdit = true;
}
} else {
if (isCompletedLocally) {
statusLabel = 'Complété';
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'À compléter';
statusColor = 'gray';
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : '';
borderClass = isActive ? 'border border-gray-300' : '';
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
canEdit = true;
}
}
return (
<li
key={master.id}
key={tpl.id}
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-700 font-semibold'
: isCompleted
? 'text-green-600 hover:bg-green-50'
: 'text-gray-600 hover:bg-gray-100'
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${bgClass} ${borderClass} ${textClass}`
}`}
onClick={() => setCurrentTemplateIndex(index)}
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
>
<span className="mr-3">
{getFormStatusIcon(master.id, isActive)}
</span>
<span className="mr-3">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">
{master.formMasterData?.title ||
master.title ||
master.name ||
'Formulaire sans nom'}
<div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
{statusLabel}
</span>
</div>
{isCompleted ? (
<div className="text-xs text-green-600">
Complété -{' '}
{
Object.keys(
formsData[master.id] ||
existingResponses[master.id] ||
{}
).length
}{' '}
réponse(s)
</div>
) : (
<div className="text-xs text-gray-500">
{master.formMasterData?.fields || master.fields
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
{tpl.formMasterData?.fields || tpl.fields
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
: 'À compléter'}
</div>
)}
</div>
</li>
);
})}
</ul>
);
})()}
</div>
{/* Affichage du formulaire actuel */}
<div className="w-3/4">
{currentTemplate && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="mb-6">
<h3 className="text-xl font-semibold text-gray-800 mb-2">
{currentTemplate.formTemplateData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire sans nom'}
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-800">
{currentTemplate.name}
</h3>
{/* Label d'état */}
{currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
)}
</div>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
currentTemplate.description ||
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
currentTemplate.description || ''}
</p>
<div className="text-xs text-gray-500 mt-1">
Formulaire {currentTemplateIndex + 1} sur{' '}
{schoolFileMasters.length}
Formulaire {(() => {
// Trouver l'index du template courant dans la liste triée
const getState = tpl => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
return idx + 1;
})()} sur {schoolFileTemplates.length}
</div>
</div>
@ -283,18 +390,34 @@ export default function DynamicFormsList({
submitLabel:
currentTemplate.formTemplateData?.submitLabel || 'Valider',
}}
initialValues={
formsData[currentTemplate.id] ||
existingResponses[currentTemplate.id] ||
{}
}
onFormSubmit={(formData) =>
handleFormSubmit(formData, currentTemplate.id)
}
// Désactive le bouton suivant si le template est validé
enable={currentTemplate.isValidated !== true}
/>
) : (
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-2">
<FileText className="w-16 h-16 text-gray-400" />
<div className="text-lg font-semibold text-gray-700">
{currentTemplate.name}
</div>
{/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && (
<iframe
src={`${BASE_URL}${currentTemplate.file}`}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
/>
)}
{/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && (
<div className="flex flex-col items-center gap-4 w-full">
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
@ -307,22 +430,26 @@ export default function DynamicFormsList({
Télécharger le document
</a>
)}
</div>
{/* Composant d'upload */}
{enable && (
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
required
enable
enable={true}
/>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Message de fin */}
{currentTemplateIndex >= schoolFileMasters.length && (
{currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-green-600 mb-2">

View File

@ -77,7 +77,7 @@ export default function InscriptionFormShared({
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [formResponses, setFormResponses] = useState({});
const [currentPage, setCurrentPage] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const [isPage1Valid, setIsPage1Valid] = useState(false);
const [isPage2Valid, setIsPage2Valid] = useState(false);
@ -799,7 +799,7 @@ export default function InscriptionFormShared({
{/* Page 5 : Formulaires dynamiques d'inscription */}
{currentPage === 5 && (
<DynamicFormsList
schoolFileMasters={schoolFileTemplates}
schoolFileTemplates={schoolFileTemplates}
existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange}

View File

@ -100,7 +100,7 @@ export default function ResponsableInputFields({
profile_role_data: {
establishment: selectedEstablishmentId,
role_type: 2,
is_active: true,
is_active: false,
profile_data: {
email: '',
password: 'Provisoire01!',

View File

@ -1,5 +1,6 @@
'use client';
import React, { useState, useEffect } from 'react';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url';
@ -8,24 +9,55 @@ import {
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button';
export default function ValidateSubscription({
studentId,
firstName,
email,
lastName,
sepa_file,
student_file,
onAccept,
onRefuse,
classes,
handleValidateOrRefuseDoc,
csrfToken,
}) {
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [mergeDocuments, setMergeDocuments] = useState(false);
const [isPageValid, setIsPageValid] = useState(false);
// Pour la validation/refus des documents
const [docStatuses, setDocStatuses] = useState({}); // {index: 'accepted'|'refused'}
// Met à jour docStatuses selon isValidated des templates récupérés
useEffect(() => {
// On construit la map index -> status à partir des templates
const newStatuses = {};
// Fiche élève (pas de validation individuelle)
newStatuses[0] = undefined;
// School templates
schoolFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + i] = tpl.isValidated ? 'accepted' : 'refused';
}
});
// Parent templates
parentFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
}
});
setDocStatuses(s => ({ ...s, ...newStatuses }));
}, [schoolFileTemplates, parentFileTemplates]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
const [formData, setFormData] = useState({
associated_class: null,
@ -88,14 +120,27 @@ export default function ValidateSubscription({
},
status: 5,
fusionParam: mergeDocuments,
notes: 'Dossier validé',
};
onAccept(data);
} else {
logger.warn('Aucune classe sélectionnée.');
}
};
const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés
let notes = 'Dossier non validé pour les raisons suivantes :\n';
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
const data = {
status: 2,
notes,
};
if (onRefuse) {
onRefuse(data);
}
};
const onChange = (field, value) => {
setFormData((prev) => ({
...prev,
@ -125,6 +170,17 @@ export default function ValidateSubscription({
]
: []),
];
// Récupère la liste des documents refusés (inclut la fiche élève si refusée)
const refusedDocs = allTemplates
.map((doc, idx) => ({ ...doc, idx }))
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
logger.debug(allTemplates);
return (
@ -162,8 +218,8 @@ export default function ValidateSubscription({
)}
</div>
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
<div className="w-1/4 flex flex-col gap-4">
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
{/* Liste des documents */}
<div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
@ -187,60 +243,176 @@ export default function ValidateSubscription({
<FileText className="w-5 h-5 text-green-600" />
)}
</span>
{template.name}
<span className="flex-1">{template.name}</span>
{/* 2 boutons : Validé / Refusé (sauf fiche élève) */}
{index !== 0 && (
<span className="ml-2 flex gap-1">
<button
type="button"
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
// Appel API pour valider le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: true,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Validé
</button>
<button
type="button"
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'}
onClick={e => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: false,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Refusé
</button>
</span>
)}
</li>
))}
</ul>
</div>
{/* Option de fusion */}
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Option de fusion
</h3>
<div className="flex items-center justify-between">
{/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
{allChecked && allValidated && (
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
<div>
<SelectChoice
name="associated_class"
label="Liste des classes"
placeHolder="Sélectionner une classe"
selected={formData.associated_class || ''}
callback={(e) => onChange('associated_class', e.target.value)}
choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))}
required
className="w-full"
/>
</div>
<div className="flex justify-end items-center mt-2">
<ToggleSwitch
label="Fusionner les documents"
checked={mergeDocuments}
onChange={handleToggleMergeDocuments}
className="ml-0"
/>
</div>
</div>
)}
{/* Section Affectation */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Affectation à une classe
</h3>
<div className="flex flex-col gap-4">
<SelectChoice
name="associated_class"
label="Classe"
placeHolder="Sélectionner une classe"
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData
choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))} // Liste des classes disponibles
required
/>
{/* Boutons Valider/Refuser en bas, centrés */}
<div className="mt-auto py-4">
<Button
text="Valider le dossier d'inscription"
onClick={(e) => {
text="Soumettre"
onClick={e => {
e.preventDefault();
handleAssignClass();
// 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
if (allChecked && hasRefused) {
setShowRefusedPopup(true);
return;
}
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
if (allChecked && allValidated && formData.associated_class) {
setShowFinalValidationPopup(true);
}
}}
primary
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
!isPageValid
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!allChecked || (allChecked && allValidated && !formData.associated_class)
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={!isPageValid}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
}
/>
</div>
</div>
{/* Popup de confirmation si refus */}
<Popup
isOpen={showRefusedPopup}
onCancel={() => setShowRefusedPopup(false)}
onConfirm={() => {
setShowRefusedPopup(false);
handleRefuseDossier();
}}
message={
<span>
{`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
<span className="font-semibold text-blue-700">{email}</span>
{' avec la liste des documents non validés :'}
<ul className="list-disc ml-6 mt-2">
{refusedDocs.map(doc => (
<li key={doc.idx}>{doc.name}</li>
))}
</ul>
</span>
}
/>
{/* Popup de confirmation finale si tous validés et classe sélectionnée */}
<Popup
isOpen={showFinalValidationPopup}
onCancel={() => setShowFinalValidationPopup(false)}
onConfirm={() => {
setShowFinalValidationPopup(false);
handleAssignClass();
}}
message={
<span>
{`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
</span>
}
/>
</div>
</div>
</div>

View File

@ -7,7 +7,6 @@ import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button';
import { useEstablishment } from '@/context/EstablishmentContext';
import {
fetchEstablishmentCompetencies,
createEstablishmentCompetencies,
deleteEstablishmentCompetencies,
} from '@/app/actions/schoolAction';
@ -44,7 +43,7 @@ export default function CompetenciesList({
3: false,
4: false,
});
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
const csrfToken = useCsrfToken();
const { showNotification } = useNotification();
@ -280,6 +279,7 @@ export default function CompetenciesList({
</div>
{/* Bouton submit centré en bas */}
<div className="flex justify-center mb-2 mt-6">
{profileRole !== 0 && (
<Button
text="Sauvegarder"
className={`px-6 py-2 rounded-md shadow ${
@ -291,6 +291,7 @@ export default function CompetenciesList({
primary
disabled={!hasSelection}
/>
)}
</div>
{/* Légende en dessous du bouton, alignée à gauche */}
<div className="flex flex-row items-center gap-4 mb-4">

View File

@ -5,6 +5,7 @@ import React, {
useImperativeHandle,
} from 'react';
import { CheckCircle, Circle } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext';
const TreeView = forwardRef(function TreeView(
{ data, expandAll, onSelectionChange },
@ -72,6 +73,8 @@ const TreeView = forwardRef(function TreeView(
clearSelection: () => setSelectedCompetencies({}),
}));
const { profileRole } = useEstablishment();
return (
<div>
{data.map((domaine) => (
@ -112,12 +115,18 @@ const TreeView = forwardRef(function TreeView(
? 'text-emerald-600 font-semibold cursor-pointer'
: 'text-gray-500 cursor-pointer hover:text-emerald-600'
}`}
onClick={() => handleCompetenceClick(competence)}
onClick={
profileRole !== 0
? () => handleCompetenceClick(competence)
: undefined
}
style={{
cursor:
competence.state === 'required'
? 'default'
: 'pointer',
: profileRole !== 0
? 'pointer'
: 'default',
userSelect: 'none',
}}
>

View File

@ -130,9 +130,7 @@ const ClassesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [detailsModalVisible, setDetailsModalVisible] = useState(false);
const [selectedClass, setSelectedClass] = useState(null);
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses();
const router = useRouter();
@ -449,6 +447,25 @@ const ClassesSection = ({
case 'MISE A JOUR':
return classe.updated_date_formatted;
case 'ACTIONS':
// Affichage des actions en mode affichage (hors édition/création)
if (profileRole === 0) {
// Si professeur, uniquement le bouton ZoomIn
return (
<div className="flex justify-center space-x-2">
<button
type="button"
onClick={() => {
const url = `${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${classe.id}`;
router.push(`${url}`);
}}
className="text-gray-500 hover:text-gray-700"
>
<ZoomIn className="w-5 h-5" />
</button>
</div>
);
}
// Sinon, toutes les actions (admin)
return (
<div className="flex justify-center space-x-2">
<button
@ -534,7 +551,7 @@ const ClassesSection = ({
icon={Users}
title="Liste des classes"
description="Gérez les classes de votre école"
button={true}
button={profileRole !== 0}
onClick={handleAddClass}
/>
<Table

View File

@ -29,7 +29,7 @@ const SpecialitiesSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
// Récupération des messages d'erreur
const getError = (field) => {
@ -239,7 +239,7 @@ const SpecialitiesSection = ({
const columns = [
{ name: 'LIBELLE', label: 'Libellé' },
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' },
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
];
return (
@ -249,7 +249,7 @@ const SpecialitiesSection = ({
icon={BookOpen}
title="Liste des spécialités"
description="Gérez les spécialités de votre école"
button={true}
button={profileRole !== 0}
onClick={handleAddSpeciality}
/>
<Table

View File

@ -3,8 +3,7 @@ import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useCsrfToken } from '@/context/CsrfContext';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/Form/InputText';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
@ -128,7 +127,6 @@ const TeachersSection = ({
handleEdit,
handleDelete,
}) => {
const csrfToken = useCsrfToken();
const [editingTeacher, setEditingTeacher] = useState(null);
const [newTeacher, setNewTeacher] = useState(null);
const [formData, setFormData] = useState({});
@ -140,38 +138,19 @@ const TeachersSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, profileRole } = useEstablishment();
// --- UTILS ---
// Retourne le profil existant pour un email, utilisé par un teacher actif ou le teacher en cours d'édition
const getUsedProfileForEmail = (email, teacherId = null) => {
const usedProfileIds = new Set(
teachers.map(t => t.profile_role && t.profile).filter(Boolean)
);
// Ajoute le profil du teacher en cours d'édition si besoin
if (teacherId) {
const currentTeacher = teachers.find(t => t.id === teacherId);
if (currentTeacher && currentTeacher.profile_role && currentTeacher.profile) {
const profileObj = profiles.find(p => p.id === currentTeacher.profile);
if (profileObj && profileObj.email === email) {
usedProfileIds.add(profileObj.id);
}
} else {
// Cas création immédiate : on cherche le profil par email dans profiles
const profileObj = profiles.find(p => p.email === email);
if (profileObj) {
usedProfileIds.add(profileObj.id);
}
}
}
return profiles.find(
(profile) => profile.email === email && usedProfileIds.has(profile.id)
);
// Retourne le profil existant pour un email
const getUsedProfileForEmail = (email) => {
// On cherche tous les profils dont l'email correspond
const matchingProfiles = profiles.filter(p => p.email === email);
// On retourne le premier profil correspondant (ou undefined)
const result = matchingProfiles.length > 0 ? matchingProfiles[0] : undefined;
return result;
};
// Met à jour le formData et newTeacher si besoin
@ -189,7 +168,7 @@ const TeachersSection = ({
const handleEmailChange = (e) => {
const email = e.target.value;
const existingProfile = getUsedProfileForEmail(email, editingTeacher);
const existingProfile = getUsedProfileForEmail(email);
if (existingProfile) {
logger.info(`Adresse email déjà utilisée pour le profil ${existingProfile.id}`);
@ -290,9 +269,6 @@ const TeachersSection = ({
};
const handleUpdateTeacher = (id, updatedData) => {
// Simplification : le profil est forcément existant, on utilise directement existingProfileId du formData
const currentTeacher = teachers.find((teacher) => teacher.id === id);
if (
updatedData.last_name &&
updatedData.first_name &&
@ -302,7 +278,6 @@ const TeachersSection = ({
id: updatedData.profile_role,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
is_active: true,
profile: updatedData.existingProfileId,
};
@ -543,7 +518,7 @@ const TeachersSection = ({
{ name: 'SPECIALITES', label: 'Spécialités' },
{ name: 'ADMINISTRATEUR', label: 'Profil' },
{ name: 'MISE A JOUR', label: 'Mise à jour' },
{ name: 'ACTIONS', label: 'Actions' },
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
];
return (
@ -553,7 +528,7 @@ const TeachersSection = ({
icon={GraduationCap}
title="Liste des enseignants.es"
description="Gérez les enseignants.es de votre école"
button={true}
button={profileRole !== 0}
onClick={handleAddTeacher}
/>
<Table

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import {
Edit,
Trash2,
@ -192,8 +192,8 @@ function SimpleList({
export default function FilesGroupsManagement({
csrfToken,
selectedEstablishmentId,
profileRole
}) {
const [showFilePreview, setShowFilePreview] = useState(false);
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]);
@ -211,7 +211,6 @@ export default function FilesGroupsManagement({
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [showHelp, setShowHelp] = useState(false);
const { showNotification } = useNotification();
const [isFileUploadOpen, setIsFileUploadOpen] = useState(false);
const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false);
const [editingParentFile, setEditingParentFile] = useState(null);
@ -819,6 +818,7 @@ export default function FilesGroupsManagement({
<div className="flex items-center mb-4">
<SectionTitle title="Liste des dossiers d'inscriptions" />
<div className="flex-1" />
{profileRole !== 0 && (
<button
className="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
onClick={() => setIsGroupModalOpen(true)}
@ -826,6 +826,7 @@ export default function FilesGroupsManagement({
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
<SimpleList
items={groups}
@ -866,6 +867,7 @@ export default function FilesGroupsManagement({
<div className="flex items-center mb-4">
<SectionTitle title="Liste des documents" />
<div className="flex-1" />
{profileRole !== 0 && (
<DropdownMenu
buttonContent={
<span className="flex items-center">
@ -910,6 +912,7 @@ export default function FilesGroupsManagement({
dropdownOpen={isDocDropdownOpen}
setDropdownOpen={setIsDocDropdownOpen}
/>
)}
</div>
{!selectedGroupId ? (
<div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white">

View File

@ -0,0 +1,23 @@
import React from 'react';
/**
* Textarea composant réutilisable
* @param {string} value - Valeur du textarea
* @param {function} onChange - Fonction appelée lors d'un changement
* @param {string} placeholder - Texte d'exemple
* @param {number} rows - Nombre de lignes
* @param {string} className - Classes CSS additionnelles
* @param {object} props - Props additionnels
*/
const Textarea = ({ value, onChange, placeholder = '', rows = 3, className = '', ...props }) => (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
className={`border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y ${className}`}
{...props}
/>
);
export default Textarea;