9 Commits

23 changed files with 1386 additions and 403 deletions

View File

@ -225,4 +225,106 @@ def sendRegisterTeacher(recipients, establishment_id):
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection) sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e: except Exception as e:
errorMessage = str(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 return errorMessage

View File

@ -214,9 +214,28 @@ class RegistrationFileGroup(models.Model):
def __str__(self): def __str__(self):
return f'{self.group.name} - {self.id}' return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename): def registration_form_file_upload_to(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename """
return f'registration_files/dossier_rf_{instance.student_id}/{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 RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices): class RegistrationFormStatus(models.IntegerChoices):
@ -238,17 +257,17 @@ class RegistrationForm(models.Model):
notes = models.CharField(max_length=200, blank=True) notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True) registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField( registration_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
sepa_file = models.FileField( sepa_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
fusion_file = models.FileField( fusion_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
@ -285,13 +304,23 @@ class RegistrationForm(models.Model):
except RegistrationForm.DoesNotExist: except RegistrationForm.DoesNotExist:
old_fileGroup = None 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 if self.pk: # Si l'objet existe déjà dans la base de données
try: try:
old_instance = RegistrationForm.objects.get(pk=self.pk) 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: if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier _delete_file_if_exists(old_instance.sepa_file)
old_instance.sepa_file.delete(save=False)
# 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: except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer 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): 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}" 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): 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) ####### ####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model): 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) 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) file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True) formTemplateData = models.JSONField(default=list, blank=True, null=True)
isValidated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name 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 @staticmethod
def get_files_from_rf(register_form_id): def get_files_from_rf(register_form_id):
""" """
Récupère tous les fichiers liés à un dossier dinscription donné. 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) registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = [] filenames = []
for reg_file in registration_files: for reg_file in registration_files:
filenames.append(reg_file.file.path) 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 return filenames
class StudentCompetency(models.Model): 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) 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) 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) file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
isValidated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.master.name if self.master else f"ParentFile_{self.pk}"
def save(self, *args, **kwargs): 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: try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk) old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''): # Si le fichier change ou est supprimé
if os.path.exists(old_instance.file.path): if old_instance.file:
old_instance.file.delete(save=False) if old_instance.file != self.file or not self.file or self.file.name == '':
self.file = None _delete_file_if_exists(old_instance.file)
else: if not self.file or self.file.name == '':
print(f"Le fichier {old_instance.file.path} n'existe pas.") self.file = None
except RegistrationParentFileTemplate.DoesNotExist: except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.") pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
@staticmethod @staticmethod

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 random
import string import string
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger from PyPDF2 import PdfMerger, PdfReader
from PyPDF2.errors import PdfReadError
import shutil import shutil
import logging import logging
@ -31,6 +32,29 @@ from rest_framework import status
logger = logging.getLogger(__name__) 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): def build_payload_from_request(request):
""" """
Normalise la request en payload prêt à être donné au serializer. 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): def merge_files_pdf(file_paths):
""" """
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire. 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() 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: for file_path in file_paths:
merger.append(file_path) # 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 # Sauvegarder le fichier fusionné en mémoire
merged_pdf = BytesIO() merged_pdf = BytesIO()
@ -378,25 +460,11 @@ def rfToPDF(registerForm, filename):
if not pdf: if not pdf:
raise ValueError("Erreur lors de la génération du 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 # Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
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
try: try:
registerForm.registration_file.save( save_file_replacing_existing(
os.path.basename(filename), # Utiliser uniquement le nom de fichier registerForm.registration_file,
os.path.basename(filename),
File(BytesIO(pdf.content)), File(BytesIO(pdf.content)),
save=True save=True
) )

View File

@ -9,6 +9,7 @@ from drf_yasg import openapi
import json import json
import os import os
import threading
from django.core.files import File from django.core.files import File
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
@ -359,6 +360,26 @@ class RegisterFormWithIdView(APIView):
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW: 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') updateStateMachine(registerForm, 'EVENT_REFUSE')
util.delete_registration_files(registerForm) util.delete_registration_files(registerForm)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
@ -401,14 +422,23 @@ class RegisterFormWithIdView(APIView):
fileNames.extend(parent_file_templates) fileNames.extend(parent_file_templates)
# Création du fichier PDF fusionné # Création du fichier PDF fusionné
merged_pdf_content = util.merge_files_pdf(fileNames) merged_pdf_content = None
try:
merged_pdf_content = util.merge_files_pdf(fileNames)
# Mise à jour du champ registration_file avec le fichier fusionné # Mise à jour du champ fusion_file avec le fichier fusionné
registerForm.fusion_file.save( util.save_file_replacing_existing(
f"dossier_complet.pdf", registerForm.fusion_file,
File(merged_pdf_content), "dossier_complet.pdf",
save=True 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 # Valorisation des StudentCompetency pour l'élève
try: try:
student = registerForm.student student = registerForm.student
@ -450,8 +480,65 @@ class RegisterFormWithIdView(APIView):
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {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') 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 # Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False) return JsonResponse(studentForm_serializer.data, safe=False)

View File

@ -11,7 +11,6 @@ import {
Award, Award,
Calendar, Calendar,
Settings, Settings,
LogOut,
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
@ -29,16 +28,14 @@ import {
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { getGravatarUrl } from '@/utils/gravatar';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import { getRightStr, RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import ProfileSelector from '@/components/ProfileSelector';
export default function Layout({ children }) { export default function Layout({ children }) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, user, clearContext } = const { profileRole, establishments, clearContext } =
useEstablishment(); useEstablishment();
const sidebarItems = { const sidebarItems = {
@ -97,45 +94,15 @@ export default function Layout({ children }) {
const pathname = usePathname(); const pathname = usePathname();
const currentPage = pathname.split('/').pop(); const currentPage = pathname.split('/').pop();
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
const softwareName = 'N3WT School'; const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`; const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
const handleDisconnect = () => {
setIsPopupVisible(true);
};
const confirmDisconnect = () => { const confirmDisconnect = () => {
setIsPopupVisible(false); setIsPopupVisible(false);
disconnect(); disconnect();
clearContext(); 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 = () => { const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen); setIsSidebarOpen(!isSidebarOpen);
}; };
@ -145,6 +112,15 @@ export default function Layout({ children }) {
setIsSidebarOpen(false); setIsSidebarOpen(false);
}, [pathname]); }, [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 ( return (
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}> <ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Sidebar */} {/* Sidebar */}
@ -156,7 +132,7 @@ export default function Layout({ children }) {
<Sidebar <Sidebar
establishments={establishments} establishments={establishments}
currentPage={currentPage} currentPage={currentPage}
items={Object.values(sidebarItems)} items={sidebarItemsToDisplay}
onCloseMobile={toggleSidebar} onCloseMobile={toggleSidebar}
/> />
</div> </div>

View File

@ -52,7 +52,7 @@ export default function Page() {
); );
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
@ -316,35 +316,39 @@ export default function Page() {
</div> </div>
), ),
}, },
{ ...(profileRole !== 0
id: 'Fees', ? [
label: 'Tarifs', {
content: ( id: 'Fees',
<div className="h-full overflow-y-auto p-4"> label: 'Tarifs',
<FeesManagement content: (
registrationDiscounts={registrationDiscounts} <div className="h-full overflow-y-auto p-4">
setRegistrationDiscounts={setRegistrationDiscounts} <FeesManagement
tuitionDiscounts={tuitionDiscounts} registrationDiscounts={registrationDiscounts}
setTuitionDiscounts={setTuitionDiscounts} setRegistrationDiscounts={setRegistrationDiscounts}
registrationFees={registrationFees} tuitionDiscounts={tuitionDiscounts}
setRegistrationFees={setRegistrationFees} setTuitionDiscounts={setTuitionDiscounts}
tuitionFees={tuitionFees} registrationFees={registrationFees}
setTuitionFees={setTuitionFees} setRegistrationFees={setRegistrationFees}
registrationPaymentPlans={registrationPaymentPlans} tuitionFees={tuitionFees}
setRegistrationPaymentPlans={setRegistrationPaymentPlans} setTuitionFees={setTuitionFees}
tuitionPaymentPlans={tuitionPaymentPlans} registrationPaymentPlans={registrationPaymentPlans}
setTuitionPaymentPlans={setTuitionPaymentPlans} setRegistrationPaymentPlans={setRegistrationPaymentPlans}
registrationPaymentModes={registrationPaymentModes} tuitionPaymentPlans={tuitionPaymentPlans}
setRegistrationPaymentModes={setRegistrationPaymentModes} setTuitionPaymentPlans={setTuitionPaymentPlans}
tuitionPaymentModes={tuitionPaymentModes} registrationPaymentModes={registrationPaymentModes}
setTuitionPaymentModes={setTuitionPaymentModes} setRegistrationPaymentModes={setRegistrationPaymentModes}
handleCreate={handleCreate} tuitionPaymentModes={tuitionPaymentModes}
handleEdit={handleEdit} setTuitionPaymentModes={setTuitionPaymentModes}
handleDelete={handleDelete} handleCreate={handleCreate}
/> handleEdit={handleEdit}
</div> handleDelete={handleDelete}
), />
}, </div>
),
},
]
: []),
{ {
id: 'Files', id: 'Files',
label: 'Documents', label: 'Documents',
@ -353,6 +357,7 @@ export default function Page() {
<FilesGroupsManagement <FilesGroupsManagement
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
profileRole={profileRole}
/> />
</div> </div>
), ),

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tab from '@/components/Tab'; import Tab from '@/components/Tab';
import Textarea from '@/components/Textarea';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -17,6 +18,7 @@ import {
Plus, Plus,
Upload, Upload,
Eye, Eye,
XCircle,
} 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';
@ -83,12 +85,9 @@ export default function Page({ params: { locale } }) {
const [totalHistorical, setTotalHistorical] = useState(0); const [totalHistorical, setTotalHistorical] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
const [student, setStudent] = useState('');
const [classes, setClasses] = useState([]); const [classes, setClasses] = useState([]);
const [reloadFetch, setReloadFetch] = useState(false); const [reloadFetch, setReloadFetch] = useState(false);
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]); const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
@ -99,9 +98,40 @@ export default function Page({ params: { locale } }) {
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false); const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null); 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 csrfToken = useCsrfToken();
const router = useRouter(); const router = useRouter();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const openSepaUploadModal = (row) => { const openSepaUploadModal = (row) => {
@ -490,10 +520,18 @@ export default function Page({ params: { locale } }) {
</span> </span>
), ),
onClick: () => { 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}`); 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 // Etat "A relancer" - NON TESTE
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [ [RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
@ -801,15 +839,17 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
</div> </div>
<button {profileRole !== 0 && (
onClick={() => { <button
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`; onClick={() => {
router.push(url); const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
}} router.push(url);
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4" }}
> className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
<Plus className="w-5 h-5" /> >
</button> <Plus className="w-5 h-5" />
</button>
)}
</div> </div>
<div className="w-full"> <div className="w-full">
@ -853,6 +893,25 @@ export default function Page({ params: { locale } }) {
onCancel={() => setConfirmPopupVisible(false)} 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 && ( {isSepaUploadModalOpen && (
<Modal <Modal
isOpen={isSepaUploadModalOpen} isOpen={isSepaUploadModalOpen}

View File

@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url'; import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
export default function Page() { export default function Page() {
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -20,6 +22,7 @@ export default function Page() {
const studentId = searchParams.get('studentId'); const studentId = searchParams.get('studentId');
const firstName = searchParams.get('firstName'); const firstName = searchParams.get('firstName');
const lastName = searchParams.get('lastName'); const lastName = searchParams.get('lastName');
const email = searchParams.get('email');
const level = searchParams.get('level'); const level = searchParams.get('level');
const sepa_file = const sepa_file =
searchParams.get('sepa_file') === 'null' 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) { if (isLoading) {
return <Loader />; return <Loader />;
} }
@ -93,10 +135,15 @@ export default function Page() {
studentId={studentId} studentId={studentId}
firstName={firstName} firstName={firstName}
lastName={lastName} lastName={lastName}
email={email}
sepa_file={sepa_file} sepa_file={sepa_file}
student_file={student_file} student_file={student_file}
onAccept={handleAcceptRF} onAccept={handleAcceptRF}
classes={classes} classes={classes}
onRefuse={handleRefuseRF}
isLoadingRefuse={isLoadingRefuse}
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
csrfToken={csrfToken}
/> />
); );
} }

View File

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

View File

@ -1,5 +1,6 @@
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice'; import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon'; import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
@ -28,6 +29,7 @@ export function getIcon(name) {
export default function FormRenderer({ export default function FormRenderer({
formConfig, formConfig,
csrfToken, csrfToken,
initialValues = {},
onFormSubmit = (data) => { onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2)); alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel) }, // Callback de soumission personnalisé (optionnel)
@ -37,7 +39,14 @@ export default function FormRenderer({
control, control,
formState: { errors }, formState: { errors },
reset, 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 // Fonction utilitaire pour envoyer les données au backend
const sendFormDataToBackend = async (formData) => { const sendFormDataToBackend = async (formData) => {

View File

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

View File

@ -799,7 +799,7 @@ export default function InscriptionFormShared({
{/* Page 5 : Formulaires dynamiques d'inscription */} {/* Page 5 : Formulaires dynamiques d'inscription */}
{currentPage === 5 && ( {currentPage === 5 && (
<DynamicFormsList <DynamicFormsList
schoolFileMasters={schoolFileTemplates} schoolFileTemplates={schoolFileTemplates}
existingResponses={formResponses} existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit} onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange} onValidationChange={handleDynamicFormsValidationChange}

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
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 { BASE_URL } from '@/utils/Url';
@ -8,24 +9,55 @@ import {
fetchParentFileTemplatesFromRegistrationFiles, fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger'; 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 SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
export default function ValidateSubscription({ export default function ValidateSubscription({
studentId, studentId,
firstName, firstName,
email,
lastName, lastName,
sepa_file, sepa_file,
student_file, student_file,
onAccept, onAccept,
onRefuse,
classes, classes,
handleValidateOrRefuseDoc,
csrfToken,
}) { }) {
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]); const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]); const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0); const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [mergeDocuments, setMergeDocuments] = useState(false); const [mergeDocuments, setMergeDocuments] = useState(false);
const [isPageValid, setIsPageValid] = 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({ const [formData, setFormData] = useState({
associated_class: null, associated_class: null,
@ -88,14 +120,27 @@ export default function ValidateSubscription({
}, },
status: 5, status: 5,
fusionParam: mergeDocuments, fusionParam: mergeDocuments,
notes: 'Dossier validé',
}; };
onAccept(data); onAccept(data);
} else { } else {
logger.warn('Aucune classe sélectionnée.'); 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) => { const onChange = (field, value) => {
setFormData((prev) => ({ setFormData((prev) => ({
...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); logger.debug(allTemplates);
return ( return (
@ -162,8 +218,8 @@ export default function ValidateSubscription({
)} )}
</div> </div>
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */} {/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
<div className="w-1/4 flex flex-col gap-4"> <div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
{/* Liste des documents */} {/* Liste des documents */}
<div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto"> <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"> <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" /> <FileText className="w-5 h-5 text-green-600" />
)} )}
</span> </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> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* Option de fusion */} {/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200"> {allChecked && allValidated && (
<h3 className="text-lg font-semibold text-gray-800 mb-4"> <div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
Option de fusion <div>
</h3> <SelectChoice
<div className="flex items-center justify-between"> name="associated_class"
<ToggleSwitch label="Liste des classes"
label="Fusionner les documents" placeHolder="Sélectionner une classe"
checked={mergeDocuments} selected={formData.associated_class || ''}
onChange={handleToggleMergeDocuments} 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> </div>
)}
{/* Boutons Valider/Refuser en bas, centrés */}
<div className="mt-auto py-4">
<Button
text="Soumettre"
onClick={e => {
e.preventDefault();
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
// 2. Si tous cochés et au moins un refusé : popup refus
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={`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={
!allChecked || (allChecked && allValidated && !formData.associated_class)
}
/>
</div> </div>
{/* Section Affectation */} {/* Popup de confirmation si refus */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200"> <Popup
<h3 className="text-lg font-semibold text-gray-800 mb-4"> isOpen={showRefusedPopup}
Affectation à une classe onCancel={() => setShowRefusedPopup(false)}
</h3> onConfirm={() => {
<div className="flex flex-col gap-4"> setShowRefusedPopup(false);
<SelectChoice handleRefuseDossier();
name="associated_class" }}
label="Classe" message={
placeHolder="Sélectionner une classe" <span>
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée {`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData <span className="font-semibold text-blue-700">{email}</span>
choices={classes.map((classe) => ({ {' avec la liste des documents non validés :'}
value: classe.id, <ul className="list-disc ml-6 mt-2">
label: classe.atmosphere_name, {refusedDocs.map(doc => (
}))} // Liste des classes disponibles <li key={doc.idx}>{doc.name}</li>
required ))}
/> </ul>
<Button </span>
text="Valider le dossier d'inscription" }
onClick={(e) => { />
e.preventDefault();
handleAssignClass(); {/* Popup de confirmation finale si tous validés et classe sélectionnée */}
}} <Popup
primary isOpen={showFinalValidationPopup}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${ onCancel={() => setShowFinalValidationPopup(false)}
!isPageValid onConfirm={() => {
? 'bg-gray-300 text-gray-700 cursor-not-allowed' setShowFinalValidationPopup(false);
: 'bg-emerald-500 text-white hover:bg-emerald-600' handleAssignClass();
}`} }}
disabled={!isPageValid} message={
/> <span>
</div> {`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
</div> </span>
}
/>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -130,9 +130,7 @@ const ClassesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [detailsModalVisible, setDetailsModalVisible] = useState(false); const { selectedEstablishmentId, profileRole } = useEstablishment();
const [selectedClass, setSelectedClass] = useState(null);
const { selectedEstablishmentId } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses(); const { getNiveauxLabels, allNiveaux } = useClasses();
const router = useRouter(); const router = useRouter();
@ -449,6 +447,25 @@ const ClassesSection = ({
case 'MISE A JOUR': case 'MISE A JOUR':
return classe.updated_date_formatted; return classe.updated_date_formatted;
case 'ACTIONS': 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 ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
<button <button
@ -534,7 +551,7 @@ const ClassesSection = ({
icon={Users} icon={Users}
title="Liste des classes" title="Liste des classes"
description="Gérez les classes de votre école" description="Gérez les classes de votre école"
button={true} button={profileRole !== 0}
onClick={handleAddClass} onClick={handleAddClass}
/> />
<Table <Table

View File

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

View File

@ -3,8 +3,7 @@ import { Edit3, Trash2, GraduationCap, Check, X, Hand } 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 { useCsrfToken } from '@/context/CsrfContext'; import { DndProvider, useDrop } from 'react-dnd';
import { DndProvider, useDrag, 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';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
@ -128,7 +127,6 @@ const TeachersSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
}) => { }) => {
const csrfToken = useCsrfToken();
const [editingTeacher, setEditingTeacher] = useState(null); const [editingTeacher, setEditingTeacher] = useState(null);
const [newTeacher, setNewTeacher] = useState(null); const [newTeacher, setNewTeacher] = useState(null);
const [formData, setFormData] = useState({}); const [formData, setFormData] = useState({});
@ -140,7 +138,7 @@ const TeachersSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
// --- UTILS --- // --- UTILS ---
@ -520,7 +518,7 @@ const TeachersSection = ({
{ name: 'SPECIALITES', label: 'Spécialités' }, { name: 'SPECIALITES', label: 'Spécialités' },
{ name: 'ADMINISTRATEUR', label: 'Profil' }, { name: 'ADMINISTRATEUR', label: 'Profil' },
{ name: 'MISE A JOUR', label: 'Mise à jour' }, { name: 'MISE A JOUR', label: 'Mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
]; ];
return ( return (
@ -530,7 +528,7 @@ const TeachersSection = ({
icon={GraduationCap} icon={GraduationCap}
title="Liste des enseignants.es" title="Liste des enseignants.es"
description="Gérez les enseignants.es de votre école" description="Gérez les enseignants.es de votre école"
button={true} button={profileRole !== 0}
onClick={handleAddTeacher} onClick={handleAddTeacher}
/> />
<Table <Table

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Edit, Edit,
Trash2, Trash2,
@ -192,8 +192,8 @@ function SimpleList({
export default function FilesGroupsManagement({ export default function FilesGroupsManagement({
csrfToken, csrfToken,
selectedEstablishmentId, selectedEstablishmentId,
profileRole
}) { }) {
const [showFilePreview, setShowFilePreview] = useState(false);
const [schoolFileMasters, setSchoolFileMasters] = useState([]); const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]); const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
@ -211,7 +211,6 @@ export default function FilesGroupsManagement({
const [selectedGroupId, setSelectedGroupId] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [isFileUploadOpen, setIsFileUploadOpen] = useState(false);
const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false); const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false);
const [editingParentFile, setEditingParentFile] = useState(null); const [editingParentFile, setEditingParentFile] = useState(null);
@ -819,13 +818,15 @@ export default function FilesGroupsManagement({
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des dossiers d'inscriptions" /> <SectionTitle title="Liste des dossiers d'inscriptions" />
<div className="flex-1" /> <div className="flex-1" />
<button {profileRole !== 0 && (
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" <button
onClick={() => setIsGroupModalOpen(true)} 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"
title="Créer un nouveau dossier" onClick={() => setIsGroupModalOpen(true)}
> title="Créer un nouveau dossier"
<Plus className="w-5 h-5" /> >
</button> <Plus className="w-5 h-5" />
</button>
)}
</div> </div>
<SimpleList <SimpleList
items={groups} items={groups}
@ -865,52 +866,54 @@ export default function FilesGroupsManagement({
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des documents" /> <SectionTitle title="Liste des documents" />
<div className="flex-1" /> <div className="flex-1" />
<DropdownMenu {profileRole !== 0 && (
buttonContent={ <DropdownMenu
<span className="flex items-center"> buttonContent={
<Plus className="w-5 h-5" />
<ChevronDown className="w-4 h-4 ml-1" />
</span>
}
items={[
{
type: 'item',
label: (
<span className="flex items-center"> <span className="flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-600" /> <Plus className="w-5 h-5" />
Formulaire personnalisé <ChevronDown className="w-4 h-4 ml-1" />
</span> </span>
), }
onClick: () => handleDocDropdownSelect('formulaire'), items={[
}, {
{ type: 'item',
type: 'item', label: (
label: ( <span className="flex items-center">
<span className="flex items-center"> <Star className="w-5 h-5 mr-2 text-yellow-600" />
<FileText className="w-5 h-5 mr-2 text-gray-600" /> Formulaire personnalisé
Formulaire existant </span>
</span> ),
), onClick: () => handleDocDropdownSelect('formulaire'),
onClick: () => handleDocDropdownSelect('formulaire_existant'), },
}, {
{ type: 'item',
type: 'item', label: (
label: ( <span className="flex items-center">
<span className="flex items-center"> <FileText className="w-5 h-5 mr-2 text-gray-600" />
<Plus className="w-5 h-5 mr-2 text-orange-500" /> Formulaire existant
Pièce à fournir </span>
</span> ),
), onClick: () => handleDocDropdownSelect('formulaire_existant'),
onClick: () => handleDocDropdownSelect('parent'), },
}, {
]} type: 'item',
buttonClassName="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" label: (
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20" <span className="flex items-center">
dropdownOpen={isDocDropdownOpen} <Plus className="w-5 h-5 mr-2 text-orange-500" />
setDropdownOpen={setIsDocDropdownOpen} Pièce à fournir
/> </span>
</div> ),
onClick: () => handleDocDropdownSelect('parent'),
},
]}
buttonClassName="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"
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20"
dropdownOpen={isDocDropdownOpen}
setDropdownOpen={setIsDocDropdownOpen}
/>
)}
</div>
{!selectedGroupId ? ( {!selectedGroupId ? (
<div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white"> <div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white">
Sélectionner un dossier d&apos;inscription Sélectionner un dossier d&apos;inscription

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;