mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
Compare commits
5 Commits
05c68ebfaa
...
N3WTS-2-Re
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fef6d61a4 | |||
| 0501c1dd73 | |||
| 4f7d7d0024 | |||
| 8fd1b62ec0 | |||
| 3779a47417 |
@ -226,3 +226,105 @@ def sendRegisterTeacher(recipients, establishment_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errorMessage = str(e)
|
errorMessage = str(e)
|
||||||
return errorMessage
|
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
|
||||||
@ -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 d’inscription donné.
|
Récupère tous les fichiers liés à un dossier d’inscription 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:
|
||||||
|
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||||
|
if os.path.exists(reg_file.file.path):
|
||||||
filenames.append(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 == '':
|
||||||
|
_delete_file_if_exists(old_instance.file)
|
||||||
|
if not self.file or self.file.name == '':
|
||||||
self.file = None
|
self.file = None
|
||||||
else:
|
|
||||||
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
|
||||||
except RegistrationParentFileTemplate.DoesNotExist:
|
except RegistrationParentFileTemplate.DoesNotExist:
|
||||||
print("Ancienne instance introuvable.")
|
pass
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal 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>
|
||||||
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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:
|
||||||
|
# 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)
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 = None
|
||||||
|
try:
|
||||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
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,
|
||||||
|
"dossier_complet.pdf",
|
||||||
File(merged_pdf_content),
|
File(merged_pdf_content),
|
||||||
save=True
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
@ -96,6 +98,37 @@ 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, profileRole } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
@ -487,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]: [
|
||||||
@ -852,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}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
setFormsData((prevData) => {
|
||||||
|
const dataState = { ...prevData };
|
||||||
|
schoolFileTemplates.forEach((tpl) => {
|
||||||
|
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
|
||||||
|
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
|
||||||
|
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
||||||
|
|
||||||
// Marquer les formulaires avec réponses comme valides
|
if (!hasLocalData && hasServerData) {
|
||||||
const validationState = {};
|
// Pas de données locales mais données serveur : utiliser les données serveur
|
||||||
Object.keys(existingResponses).forEach((formId) => {
|
dataState[tpl.id] = existingResponses[tpl.id];
|
||||||
if (
|
} else if (!hasLocalData && !hasServerData) {
|
||||||
existingResponses[formId] &&
|
// Pas de données du tout : initialiser à vide
|
||||||
Object.keys(existingResponses[formId]).length > 0
|
dataState[tpl.id] = {};
|
||||||
) {
|
|
||||||
validationState[formId] = true;
|
|
||||||
}
|
}
|
||||||
|
// 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,19 +149,32 @@ 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) => {
|
||||||
|
const newData = {
|
||||||
...prev,
|
...prev,
|
||||||
[selectedFile.id]: true,
|
[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);
|
||||||
@ -164,7 +186,7 @@ export default function DynamicFormsList({
|
|||||||
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>
|
||||||
|
|
||||||
|
{/* Tri des templates par état */}
|
||||||
|
{(() => {
|
||||||
|
// Helper pour état
|
||||||
|
const getState = tpl => {
|
||||||
|
if (tpl.isValidated === true) return 0; // validé
|
||||||
|
const isCompletedLocally = !!(
|
||||||
|
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
|
);
|
||||||
|
if (isCompletedLocally) return 1; // complété/en attente
|
||||||
|
return 2; // à compléter/refusé
|
||||||
|
};
|
||||||
|
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
||||||
|
return getState(a) - getState(b);
|
||||||
|
});
|
||||||
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{schoolFileMasters.map((master, index) => {
|
{sortedTemplates.map((tpl, index) => {
|
||||||
const isActive = index === currentTemplateIndex;
|
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
||||||
const isCompleted = isFormCompleted(master.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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={master.id}
|
key={tpl.id}
|
||||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-100 text-blue-700 font-semibold'
|
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
||||||
: isCompleted
|
: `${bgClass} ${borderClass} ${textClass}`
|
||||||
? 'text-green-600 hover:bg-green-50'
|
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCurrentTemplateIndex(index)}
|
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
|
||||||
>
|
>
|
||||||
<span className="mr-3">
|
<span className="mr-3">{icon}</span>
|
||||||
{getFormStatusIcon(master.id, isActive)}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm truncate">
|
<div className="text-sm truncate flex items-center gap-2">
|
||||||
{master.formMasterData?.title ||
|
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
|
||||||
master.title ||
|
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
|
||||||
master.name ||
|
{statusLabel}
|
||||||
'Formulaire sans nom'}
|
</span>
|
||||||
</div>
|
</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">
|
<div className="text-xs text-gray-500">
|
||||||
{master.formMasterData?.fields || master.fields
|
{tpl.formMasterData?.fields || tpl.fields
|
||||||
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
|
||||||
: 'À compléter'}
|
: 'À compléter'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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 ||
|
|
||||||
'Formulaire sans nom'}
|
|
||||||
</h3>
|
</h3>
|
||||||
|
{/* Label d'état */}
|
||||||
|
{currentTemplate.isValidated === true ? (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
|
||||||
|
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
||||||
|
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<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,18 +390,34 @@ 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}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: '600px', border: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cas non validé : bouton télécharger + upload */}
|
||||||
|
{currentTemplate.isValidated !== true && (
|
||||||
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
|
{/* Bouton télécharger le document source */}
|
||||||
{currentTemplate.file && (
|
{currentTemplate.file && (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${currentTemplate.file}`}
|
href={`${BASE_URL}${currentTemplate.file}`}
|
||||||
@ -307,22 +430,26 @@ export default function DynamicFormsList({
|
|||||||
Télécharger le document
|
Télécharger le document
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Composant d'upload */}
|
||||||
{enable && (
|
{enable && (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
selectionMessage="Sélectionnez le fichier du document"
|
key={currentTemplate.id}
|
||||||
|
selectionMessage={'Sélectionnez le fichier du document'}
|
||||||
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
||||||
required
|
required
|
||||||
enable
|
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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
label="Liste des classes"
|
||||||
|
placeHolder="Sélectionner une classe"
|
||||||
|
selected={formData.associated_class || ''}
|
||||||
|
callback={(e) => onChange('associated_class', e.target.value)}
|
||||||
|
choices={classes.map((classe) => ({
|
||||||
|
value: classe.id,
|
||||||
|
label: classe.atmosphere_name,
|
||||||
|
}))}
|
||||||
|
required
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end items-center mt-2">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Fusionner les documents"
|
label="Fusionner les documents"
|
||||||
checked={mergeDocuments}
|
checked={mergeDocuments}
|
||||||
onChange={handleToggleMergeDocuments}
|
onChange={handleToggleMergeDocuments}
|
||||||
|
className="ml-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section Affectation */}
|
{/* Boutons Valider/Refuser en bas, centrés */}
|
||||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
<div className="mt-auto py-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
|
||||||
Affectation à une classe
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SelectChoice
|
|
||||||
name="associated_class"
|
|
||||||
label="Classe"
|
|
||||||
placeHolder="Sélectionner une classe"
|
|
||||||
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée
|
|
||||||
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData
|
|
||||||
choices={classes.map((classe) => ({
|
|
||||||
value: classe.id,
|
|
||||||
label: classe.atmosphere_name,
|
|
||||||
}))} // Liste des classes disponibles
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
text="Valider le dossier d'inscription"
|
text="Soumettre"
|
||||||
onClick={(e) => {
|
onClick={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleAssignClass();
|
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
||||||
|
// 2. Si tous cochés et au moins un refusé : popup refus
|
||||||
|
if (allChecked && hasRefused) {
|
||||||
|
setShowRefusedPopup(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
|
||||||
|
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
|
||||||
|
if (allChecked && allValidated && formData.associated_class) {
|
||||||
|
setShowFinalValidationPopup(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
primary
|
primary
|
||||||
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
|
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
||||||
!isPageValid
|
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
}`}
|
}`}
|
||||||
disabled={!isPageValid}
|
disabled={
|
||||||
|
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Popup de confirmation si refus */}
|
||||||
|
<Popup
|
||||||
|
isOpen={showRefusedPopup}
|
||||||
|
onCancel={() => setShowRefusedPopup(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowRefusedPopup(false);
|
||||||
|
handleRefuseDossier();
|
||||||
|
}}
|
||||||
|
message={
|
||||||
|
<span>
|
||||||
|
{`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
|
||||||
|
<span className="font-semibold text-blue-700">{email}</span>
|
||||||
|
{' avec la liste des documents non validés :'}
|
||||||
|
<ul className="list-disc ml-6 mt-2">
|
||||||
|
{refusedDocs.map(doc => (
|
||||||
|
<li key={doc.idx}>{doc.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Popup de confirmation finale si tous validés et classe sélectionnée */}
|
||||||
|
<Popup
|
||||||
|
isOpen={showFinalValidationPopup}
|
||||||
|
onCancel={() => setShowFinalValidationPopup(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowFinalValidationPopup(false);
|
||||||
|
handleAssignClass();
|
||||||
|
}}
|
||||||
|
message={
|
||||||
|
<span>
|
||||||
|
{`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
Front-End/src/components/Textarea.js
Normal file
23
Front-End/src/components/Textarea.js
Normal 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;
|
||||||
Reference in New Issue
Block a user