27 Commits

Author SHA1 Message Date
2fef6d61a4 feat: Gestion du refus définitif d'un dossier [N3WTS-2] 2026-03-14 11:35:19 +01:00
0501c1dd73 feat: Finalisation de la validation / refus des documents signés par les parents [N3WTS-2] 2026-03-14 11:26:20 +01:00
4f7d7d0024 feat: Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] 2026-02-22 18:34:00 +01:00
8fd1b62ec0 feat: Validation document par document [N3WTS-2] 2026-02-19 18:53:33 +01:00
3779a47417 feat: Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] 2026-02-16 17:54:20 +01:00
05c68ebfaa feat: Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] 2026-02-15 18:40:14 +01:00
195579e217 feat: Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] 2026-02-15 18:08:07 +01:00
ddcaba382e feat: Gestion de la sidebar [N3WTS-8] 2026-02-15 18:02:57 +01:00
a82483f3bd chore: Suppression code mort [N3WTS-8] 2026-02-15 17:57:48 +01:00
26d4b5633f fix: Changement des niveaux de logs [N3WTS-1] 2026-02-15 17:39:19 +01:00
d66db1b019 feat: Envoi mail d'inscription au second responsable [N3WTS-1] 2026-02-15 17:36:55 +01:00
bd7dc2b0c2 feat: Envoi mail d'inscription aux enseignants [N3WTS-1] 2026-02-15 16:37:43 +01:00
176edc5c45 fix: Edition d'un teacher, champ email désactivé [N3WTS-1] 2026-02-15 15:47:51 +01:00
92c6a31740 fix: Suppression d'un PROFILE si aucun PROFILE_ROLE n'y est associé [N3WTS-1] 2026-02-14 17:58:47 +01:00
9dff32b388 feat: WIP finalisation partie signature des parents [N3WTS-17] 2026-02-13 17:06:21 +01:00
abb4b525b2 feat: Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] 2026-01-25 11:01:22 +01:00
b4f70e6bad feat: Sauvegarde des formulaires d'école dans les bons dossiers /
utilisation des bons composants dans les modales [N3WTS-17]
2026-01-18 18:44:13 +01:00
8549699dec feat: Réorganisation items dans la page [N3WTS-17] 2026-01-05 14:56:36 +01:00
a034149eae fix: Coquille [N3WTS-17] 2026-01-03 17:53:53 +01:00
12f5fc7aa9 feat: Changement du rendu de la page des documents + gestion des
formulaires d'école déjà existants [N3WTS-17]
2026-01-03 17:49:25 +01:00
2dc0dfa268 fix: Lint 2025-12-14 16:49:48 +01:00
dd00cba385 feat: Précablage du formulaire dynamique [N3WTS-17] 2025-11-30 17:24:25 +01:00
7486f6c5ce feat: Traitement de clonages des templates de documents dans le back
uniquement [#N3WTS-17]
2025-11-29 16:43:51 +01:00
1e5bc6ccba feat: Début de suppression de docuseal côté Front [#N3WTS-17] 2025-11-29 12:20:14 +01:00
0fb668b212 feat: push test [#N3WTS-17] 2025-11-29 11:33:21 +01:00
5e62ee5100 feat: Ajout des composants manquant dans le FormTemplateBuilder [N3WTS-17] 2025-09-01 12:09:19 +02:00
e89d2fc4c3 feat: Ajout FormTemplateBuilder [N3WTS-17] 2025-09-01 11:08:21 +02:00
108 changed files with 8413 additions and 2274 deletions

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.contrib.auth.models
import django.contrib.auth.validators

View File

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

View File

@ -236,7 +236,6 @@ def makeToken(user):
"establishment__name": role.establishment.name,
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
"establishment__total_capacity": role.establishment.total_capacity,
"establishment__api_docuseal": role.establishment.api_docuseal,
"establishment__logo": logo_url,
})

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
from django.db import migrations, models

View File

@ -1 +0,0 @@
# This file is intentionally left blank to make this directory a Python package.

View File

@ -1,9 +0,0 @@
from django.urls import path, re_path
from .views import generate_jwt_token, clone_template, remove_template, download_template
urlpatterns = [
re_path(r'generateToken$', generate_jwt_token, name='generate_jwt_token'),
re_path(r'cloneTemplate$', clone_template, name='clone_template'),
re_path(r'removeTemplate/(?P<id>[0-9]+)$', remove_template, name='remove_template'),
re_path(r'downloadTemplate/(?P<slug>[\w-]+)$', download_template, name='download_template')
]

View File

@ -1,200 +0,0 @@
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
import jwt
import datetime
import requests
from Establishment.models import Establishment
@csrf_exempt
@api_view(['POST'])
def generate_jwt_token(request):
# Récupérer l'établissement concerné (par ID ou autre info transmise)
establishment_id = request.data.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
# Récupérer les données de la requête
user_email = request.data.get('user_email')
documents_urls = request.data.get('documents_urls', [])
template_id = request.data.get('id')
if not user_email:
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
# Utiliser la clé API de l'établissement comme secret JWT
jwt_secret = establishment.api_docuseal
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
payload = {
'user_email': user_email,
'documents_urls': documents_urls,
'template_id': template_id,
'exp': datetime.datetime.utcnow() + expiration_delta
}
token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm)
return Response({'token': token}, status=status.HTTP_200_OK)
@csrf_exempt
@api_view(['POST'])
def clone_template(request):
# Récupérer l'établissement concerné
establishment_id = request.data.get('establishment_id')
print(f"establishment_id : {establishment_id}")
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
# Récupérer les données de la requête
document_id = request.data.get('templateId')
email = request.data.get('email')
is_required = request.data.get('is_required')
# Vérifier les données requises
if not document_id:
return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST)
# URL de l'API de DocuSeal pour cloner le template
clone_url = f'https://docuseal.com/api/templates/{document_id}/clone'
# Faire la requête pour cloner le template
try:
response = requests.post(clone_url, headers={
'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal
})
if response.status_code != status.HTTP_200_OK:
return Response({'error': 'Failed to clone template'}, status=response.status_code)
data = response.json()
if is_required:
# URL de l'API de DocuSeal pour créer une submission
submission_url = f'https://docuseal.com/api/submissions'
try:
clone_id = data['id']
response = requests.post(submission_url, json={
'template_id': clone_id,
'send_email': False,
'submitters': [{'email': email}]
}, headers={
'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal
})
if response.status_code != status.HTTP_200_OK:
return Response({'error': 'Failed to create submission'}, status=response.status_code)
data = response.json()
data[0]['id'] = clone_id
return Response(data[0], status=status.HTTP_200_OK)
except requests.RequestException as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else:
print(f'NOT REQUIRED -> on ne crée pas de submission')
return Response(data, status=status.HTTP_200_OK)
except requests.RequestException as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@csrf_exempt
@api_view(['DELETE'])
def remove_template(request, id):
# Récupérer l'établissement concerné
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
# URL de l'API de DocuSeal pour supprimer le template
clone_url = f'https://docuseal.com/api/templates/{id}'
try:
response = requests.delete(clone_url, headers={
'X-Auth-Token': establishment.api_docuseal
})
if response.status_code != status.HTTP_200_OK:
return Response({'error': 'Failed to remove template'}, status=response.status_code)
data = response.json()
return Response(data, status=status.HTTP_200_OK)
except requests.RequestException as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@csrf_exempt
@api_view(['GET'])
def download_template(request, slug):
# Récupérer l'établissement concerné
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
# Vérifier les données requises
if not slug:
return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST)
# URL de l'API de DocuSeal pour télécharger le template
download_url = f'https://docuseal.com/submitters/{slug}/download'
try:
response = requests.get(download_url, headers={
'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal
})
if response.status_code != status.HTTP_200_OK:
return Response({'error': 'Failed to download template'}, status=response.status_code)
data = response.json()
return Response(data, status=status.HTTP_200_OK)
except requests.RequestException as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,5 +1,6 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import Establishment.models
import django.contrib.postgres.fields
from django.db import migrations, models
@ -24,6 +25,7 @@ class Migration(migrations.Migration):
('licence_code', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
],
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='api_docuseal',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-31 09:56
import Establishment.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0002_establishment_api_docuseal'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='logo',
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
),
]

View File

@ -27,7 +27,6 @@ class Establishment(models.Model):
licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
logo = models.FileField(
upload_to=registration_logo_upload_to,
null=True,

View File

@ -1,6 +1,8 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
@ -14,6 +16,39 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255, null=True)),
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField()),
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
('file_url', models.URLField(blank=True, null=True)),
('file_name', models.CharField(blank=True, max_length=255, null=True)),
('file_size', models.BigIntegerField(blank=True, null=True)),
('file_type', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_edited', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='Messagerie',
fields=[
@ -27,4 +62,40 @@ class Migration(migrations.Migration):
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='UserPresence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ConversationParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('joined_at', models.DateTimeField(auto_now_add=True)),
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('conversation', 'participant')},
},
),
migrations.CreateModel(
name='MessageRead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'participant')},
},
),
]

View File

@ -1,87 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:40
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('GestionMessagerie', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255, null=True)),
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField()),
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
('file_url', models.URLField(blank=True, null=True)),
('file_name', models.CharField(blank=True, max_length=255, null=True)),
('file_size', models.BigIntegerField(blank=True, null=True)),
('file_type', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_edited', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='UserPresence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ConversationParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('joined_at', models.DateTimeField(auto_now_add=True)),
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('conversation', 'participant')},
},
),
migrations.CreateModel(
name='MessageRead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'participant')},
},
),
]

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
from django.conf import settings

View File

@ -1,8 +0,0 @@
{
"hostSMTP": "",
"portSMTP": 25,
"username": "",
"password": "",
"useSSL": false,
"useTLS": false
}

View File

@ -1,4 +1,4 @@
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
@ -17,9 +17,12 @@ def getConnection(id_establishement):
try:
# Récupérer l'instance de l'établissement
establishment = Establishment.objects.get(id=id_establishement)
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
try:
# Récupérer les paramètres SMTP associés à l'établissement
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
# Créer une connexion SMTP avec les paramètres récupérés
connection = get_connection(
@ -32,9 +35,11 @@ def getConnection(id_establishement):
)
return connection
except SMTPSettings.DoesNotExist:
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist:
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
@ -53,11 +58,13 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
plain_message = strip_tags(message)
if connection is not None:
from_email = username
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
else:
from_email = settings.EMAIL_HOST_USER
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
logger.info(f"From email: {from_email}")
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
email = EmailMultiAlternatives(
subject=subject,
@ -79,6 +86,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
logger.error(f"Settings : {connection}")
logger.error(f"Settings : {connection}")
logger.error(f"Type d'erreur: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
@ -198,4 +207,124 @@ def isValid(message, fiche_inscription):
responsable = eleve.getMainGuardian()
mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
def sendRegisterTeacher(recipients, establishment_id):
errorMessage = ''
try:
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'email': recipients,
'establishment': establishment_id
}
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/inscription_teacher.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage
def sendRefusDossier(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que son dossier d'inscription
nécessite des corrections.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus (contenu du champ notes du RegistrationForm)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription - Corrections requises'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_SUBJECT
html_message = render_to_string('emails/refus_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus: {errorMessage}")
return errorMessage
def sendValidationDossier(recipients, establishment_id, student_name, class_name=None):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été validé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
class_name: Nom de la classe attribuée (optionnel)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_VALIDATION_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription validé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'class_name': class_name
}
connection = getConnection(establishment_id)
subject = EMAIL_VALIDATION_SUBJECT
html_message = render_to_string('emails/validation_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de validation envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de validation: {errorMessage}")
return errorMessage
def sendRefusDefinitif(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été définitivement refusé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_DEFINITIF_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription refusé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_DEFINITIF_SUBJECT
html_message = render_to_string('emails/refus_definitif.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus définitif envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus définitif: {errorMessage}")
return errorMessage

View File

@ -349,13 +349,6 @@ SIMPLE_JWT = {
'TOKEN_TYPE_CLAIM': 'token_type',
}
# Configuration for DocuSeal JWT
DOCUSEAL_JWT = {
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'EXPIRATION_DELTA': timedelta(hours=1)
}
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'

View File

@ -46,7 +46,6 @@ urlpatterns = [
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
path("School/", include(("School.urls", 'School'), namespace='School')),
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
from django.db import migrations, models

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.contrib.postgres.fields
import django.db.models.deletion

View File

@ -60,6 +60,7 @@ class TeacherSerializer(serializers.ModelSerializer):
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
associated_profile_email = serializers.SerializerMethodField()
profile = serializers.SerializerMethodField()
class Meta:
model = Teacher
@ -155,6 +156,12 @@ class TeacherSerializer(serializers.ModelSerializer):
return obj.profile_role.role_type
return None
def get_profile(self, obj):
# Retourne l'id du profile associé via profile_role
if obj.profile_role and obj.profile_role.profile:
return obj.profile_role.profile.id
return None
class PlanningSerializer(serializers.ModelSerializer):
class Meta:
model = Planning

View File

@ -35,6 +35,7 @@ from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency
from Subscriptions.util import getCurrentSchoolYear
import logging
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
logger = logging.getLogger(__name__)
@ -102,8 +103,17 @@ class TeacherListCreateView(APIView):
teacher_serializer = TeacherSerializer(data=teacher_data)
if teacher_serializer.is_valid():
teacher_serializer.save()
teacher_instance = teacher_serializer.save()
# Envoi du mail d'inscription enseignant uniquement à la création
email = None
establishment_id = None
if hasattr(teacher_instance, "profile_role") and teacher_instance.profile_role:
if hasattr(teacher_instance.profile_role, "profile") and teacher_instance.profile_role.profile:
email = teacher_instance.profile_role.profile.email
if hasattr(teacher_instance.profile_role, "establishment") and teacher_instance.profile_role.establishment:
establishment_id = teacher_instance.profile_role.establishment.id
if email and establishment_id:
sendRegisterTeacher(email, establishment_id)
return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False)
@ -118,17 +128,43 @@ class TeacherDetailView(APIView):
return JsonResponse(teacher_serializer.data, safe=False)
def put(self, request, id):
teacher_data=JSONParser().parse(request)
teacher_data = JSONParser().parse(request)
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
# Récupérer l'ancien profile avant modification
old_profile_role = getattr(teacher, 'profile_role', None)
old_profile = getattr(old_profile_role, 'profile', None) if old_profile_role else None
teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
if teacher_serializer.is_valid():
teacher_serializer.save()
# Après modification, vérifier si l'ancien profile n'a plus de ProfileRole
if old_profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=old_profile).exists():
old_profile.delete()
return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False)
def delete(self, request, id):
return delete_object(Teacher, id, related_field='profile_role')
# Suppression du Teacher et du ProfileRole associé
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
profile_role = getattr(teacher, 'profile_role', None)
profile = getattr(profile_role, 'profile', None) if profile_role else None
# Supprime le Teacher (ce qui supprime le ProfileRole via on_delete=models.CASCADE)
response = delete_object(Teacher, id, related_field='profile_role')
# Si un profile était associé, vérifier s'il reste des ProfileRole
if profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=profile).exists():
profile.delete()
return response
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import django.db.models.deletion
from django.db import migrations, models

View File

@ -1,18 +0,0 @@
{
"activationMailRelance": "Oui",
"delaiRelance": "30",
"ambiances": [
"2-3 ans",
"3-6 ans",
"6-12 ans"
],
"genres": [
"Fille",
"Garçon"
],
"modesPaiement": [
"Chèque",
"Virement",
"Prélèvement SEPA"
]
}

View File

@ -0,0 +1,43 @@
"""
Management command pour tester la configuration email Django
"""
from django.core.management.base import BaseCommand
from django.core.mail import send_mail
from django.conf import settings
from N3wtSchool.mailManager import getConnection, sendMail
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Test de la configuration email'
def add_arguments(self, parser):
parser.add_argument('--establishment-id', type=int, help='ID de l\'établissement pour test')
parser.add_argument('--email', type=str, default='test@example.com', help='Email de destination')
def handle(self, *args, **options):
self.stdout.write("=== Test de configuration email ===")
# Affichage de la configuration
self.stdout.write(f"EMAIL_HOST: {settings.EMAIL_HOST}")
self.stdout.write(f"EMAIL_PORT: {settings.EMAIL_PORT}")
self.stdout.write(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}")
self.stdout.write(f"EMAIL_HOST_PASSWORD: {settings.EMAIL_HOST_PASSWORD}")
self.stdout.write(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}")
self.stdout.write(f"EMAIL_USE_SSL: {settings.EMAIL_USE_SSL}")
self.stdout.write(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}")
# Test 1: Configuration par défaut Django
self.stdout.write("\n--- Test : Configuration EMAIL par défaut ---")
try:
result = send_mail(
'Test Django Email',
'Ceci est un test de la configuration email par défaut.',
settings.EMAIL_HOST_USER,
[options['email']],
fail_silently=False,
)
self.stdout.write(self.style.SUCCESS(f"✅ Email envoyé avec succès (résultat: {result})"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ Erreur: {e}"))

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
# Generated by Django 5.1.3 on 2025-11-30 11:02
import Subscriptions.models
import django.db.models.deletion
@ -46,10 +46,11 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='RegistrationSchoolFileTemplate',
fields=[
('id', models.IntegerField(primary_key=True, serialize=False)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.CharField(default='', max_length=255)),
('name', models.CharField(default='', max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
],
),
migrations.CreateModel(
@ -153,7 +154,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=255)),
('description', models.CharField(blank=True, null=True)),
('description', models.CharField(blank=True, max_length=500, null=True)),
('is_required', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
],
@ -161,9 +162,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='RegistrationSchoolFileMaster',
fields=[
('id', models.IntegerField(primary_key=True, serialize=False)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=255)),
('is_required', models.BooleanField(default=False)),
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
],
),

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Subscriptions', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='registrationparentfilemaster',
name='description',
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View File

@ -2,11 +2,9 @@ from django.db import models
from django.utils.timezone import now
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import os
import logging
import os
logger = logging.getLogger("SubscriptionModels")
@ -216,9 +214,28 @@ class RegistrationFileGroup(models.Model):
def __str__(self):
return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
def registration_form_file_upload_to(instance, filename):
"""
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
student_last = instance.student.last_name if instance.student else "unknown"
student_first = instance.student.first_name if instance.student else "unknown"
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
def _delete_file_if_exists(file_field):
"""
Supprime le fichier physique s'il existe.
Utile pour éviter les suffixes automatiques Django lors du remplacement.
"""
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"Fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
class RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices):
@ -240,17 +257,17 @@ class RegistrationForm(models.Model):
notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
sepa_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
fusion_file = models.FileField(
upload_to=registration_file_path,
upload_to=registration_form_file_upload_to,
null=True,
blank=True
)
@ -277,32 +294,213 @@ class RegistrationForm(models.Model):
return "RF_" + self.student.last_name + "_" + self.student.first_name
def save(self, *args, **kwargs):
# Vérifier si un fichier existant doit être remplacé
# Préparer le flag de création / changement de fileGroup
was_new = self.pk is None
old_fileGroup = None
if not was_new:
try:
old_instance = RegistrationForm.objects.get(pk=self.pk)
old_fileGroup = old_instance.fileGroup
except RegistrationForm.DoesNotExist:
old_fileGroup = None
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
if self.pk: # Si l'objet existe déjà dans la base de données
try:
old_instance = RegistrationForm.objects.get(pk=self.pk)
# Gestion du sepa_file
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier
old_instance.sepa_file.delete(save=False)
_delete_file_if_exists(old_instance.sepa_file)
# Gestion du registration_file
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
_delete_file_if_exists(old_instance.registration_file)
# Gestion du fusion_file
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
_delete_file_if_exists(old_instance.fusion_file)
except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer
# Appeler la méthode save originale
super().save(*args, **kwargs)
# Après save : si nouveau ou changement de fileGroup -> créer les templates
fileGroup_changed = (self.fileGroup is not None) and (old_fileGroup is None or (old_fileGroup and old_fileGroup.id != self.fileGroup.id))
if was_new or fileGroup_changed:
try:
import Subscriptions.util as util
created = util.create_templates_for_registration_form(self)
if created:
logger.info("Created %d templates for RegistrationForm %s", len(created), self.pk)
except Exception as e:
logger.exception("Error creating templates for RegistrationForm %s: %s", self.pk, e)
#############################################################
####################### MASTER FILES ########################
#############################################################
####### DocuSeal masters (documents école, à signer ou pas) #######
####### Formulaires masters (documents école, à signer ou pas) #######
def registration_school_file_master_upload_to(instance, filename):
# Stocke les fichiers masters dans un dossier dédié
# Utilise l'ID si le nom n'est pas encore disponible
est_name = None
if instance.establishment and instance.establishment.name:
est_name = instance.establishment.name
else:
# fallback si pas d'établissement (devrait être rare)
est_name = "unknown_establishment"
return f"{est_name}/Formulaires/{filename}"
class RegistrationSchoolFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
id = models.IntegerField(primary_key=True)
name = models.CharField(max_length=255, default="")
is_required = models.BooleanField(default=False)
formMasterData = models.JSONField(default=list, blank=True, null=True)
file = models.FileField(
upload_to=registration_school_file_master_upload_to,
null=True,
blank=True,
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
def __str__(self):
return f'{self.group.name} - {self.id}'
return f'{self.name} - {self.id}'
@property
def file_url(self):
if self.file and hasattr(self.file, 'url'):
return self.file.url
return None
def save(self, *args, **kwargs):
affected_rf_ids = set()
is_new = self.pk is None
# Log création ou modification du master
if is_new:
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
else:
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
if self.pk:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
if old.file and old.file.name:
old_filename = os.path.basename(old.file.name)
# Nouveau nom selon le type (dynamique ou existant)
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
new_filename = f"{self.name}.pdf"
else:
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
extension = os.path.splitext(old_filename)[1]
new_filename = f"{self.name}{extension}" if extension else self.name
if new_filename and old_filename != new_filename:
old_file_path = old.file.path
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
# Correction du nom du fichier pour éviter le suffixe random
if (
not self.formMasterData
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
):
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
if self.file and self.file.name:
current_filename = os.path.basename(self.file.name)
current_path = self.file.path
expected_filename = new_filename
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
if current_filename != expected_filename:
try:
if os.path.exists(current_path):
os.rename(current_path, expected_path)
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# --- Traitement PDF dynamique AVANT le super().save() ---
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
from Subscriptions.util import generate_form_json_pdf
pdf_filename = f"{self.name}.pdf"
pdf_file = generate_form_json_pdf(self, self.formMasterData)
self.file.save(pdf_filename, pdf_file, save=False)
super().save(*args, **kwargs)
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
try:
# Import local pour éviter le circular import
from Subscriptions.util import create_templates_for_registration_form
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
# Détermination des RF concernés
if is_new:
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
)
else:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
old_groups = set(old.groups.values_list('id', flat=True))
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
)
form_data_changed = (
old.formMasterData != self.formMasterData
and self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
)
name_changed = old.name != self.name
if form_data_changed or name_changed:
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# Pour chaque RF concerné, régénérer les templates
for rf_id in affected_rf_ids:
try:
rf = RegistrationForm.objects.get(pk=rf_id)
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
create_templates_for_registration_form(rf)
except Exception as e:
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
except Exception as e:
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
def delete(self, *args, **kwargs):
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
# Import local pour éviter le circular import
from Subscriptions.models import RegistrationSchoolFileTemplate
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
for tmpl in templates:
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
try:
self.file.delete(save=False)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
super().delete(*args, **kwargs)
####### Parent files masters (documents à fournir par les parents) #######
class RegistrationParentFileMaster(models.Model):
@ -316,32 +514,57 @@ class RegistrationParentFileMaster(models.Model):
############################################################
def registration_school_file_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}"
"""
Génère le chemin pour les fichiers templates école.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
def registration_parent_file_upload_to(instance, filename):
return f"registration_files/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}"
####### DocuSeal templates (par dossier d'inscription) #######
####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model):
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
id = models.IntegerField(primary_key=True)
slug = models.CharField(max_length=255, default="")
name = models.CharField(max_length=255, default="")
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
if old_instance.file and old_instance.file != self.file:
_delete_file_if_exists(old_instance.file)
except RegistrationSchoolFileTemplate.DoesNotExist:
pass
super().save(*args, **kwargs)
@staticmethod
def get_files_from_rf(register_form_id):
"""
Récupère tous les fichiers liés à un dossier dinscription donné.
Ignore les fichiers qui n'existent pas physiquement.
"""
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = []
for reg_file in registration_files:
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
class StudentCompetency(models.Model):
@ -371,22 +594,24 @@ class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name
return self.master.name if self.master else f"ParentFile_{self.pk}"
def save(self, *args, **kwargs):
if self.pk: # Si l'objet existe déjà dans la base de données
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''):
if os.path.exists(old_instance.file.path):
old_instance.file.delete(save=False)
self.file = None
else:
print(f"Le fichier {old_instance.file.path} n'existe pas.")
# Si le fichier change ou est supprimé
if old_instance.file:
if old_instance.file != self.file or not self.file or self.file.name == '':
_delete_file_if_exists(old_instance.file)
if not self.file or self.file.name == '':
self.file = None
except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.")
pass
super().save(*args, **kwargs)
@staticmethod

View File

@ -1,15 +1,15 @@
from rest_framework import serializers
from .models import (
RegistrationFileGroup,
RegistrationForm,
Student,
Guardian,
Sibling,
RegistrationFileGroup,
RegistrationForm,
Student,
Guardian,
Sibling,
Language,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
AbsenceManagement,
BilanCompetence
)
@ -21,6 +21,7 @@ from N3wtSchool import settings
from django.utils import timezone
import pytz
import Subscriptions.util as util
from N3wtSchool.mailManager import sendRegisterForm
class AbsenceManagementSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField()
@ -95,7 +96,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = RegistrationForm
fields = ['student_id', 'last_name', 'first_name', 'guardians']
def get_last_name(self, obj):
return obj.student.last_name
@ -164,12 +165,20 @@ class StudentSerializer(serializers.ModelSerializer):
if guardian_id:
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
guardian_instance, created = Guardian.objects.update_or_create(
id=guardian_id,
defaults=guardian_data
)
guardians_ids.append(guardian_instance.id)
continue
try:
guardian_instance = Guardian.objects.get(id=guardian_id)
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
for field, value in guardian_data.items():
if field != 'id': # Ne pas mettre à jour l'ID
setattr(guardian_instance, field, value)
guardian_instance.save()
guardians_ids.append(guardian_instance.id)
continue
except Guardian.DoesNotExist:
# Si le guardian n'existe pas, créer un nouveau
guardian_instance = Guardian.objects.create(**guardian_data)
guardians_ids.append(guardian_instance.id)
continue
if profile_role_data:
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
@ -207,6 +216,14 @@ class StudentSerializer(serializers.ModelSerializer):
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
profile_role_serializer.is_valid(raise_exception=True)
profile_role = profile_role_serializer.save()
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
email = None
if profile_data and 'email' in profile_data:
email = profile_data['email']
elif profile_role and profile_role.profile:
email = profile_role.profile.email
if email:
sendRegisterForm(email, establishment_id)
elif profile_role:
# Récupérer un ProfileRole existant par son ID
profile_role = ProfileRole.objects.get(id=profile_role.id)

View File

@ -5,6 +5,8 @@ from Subscriptions.automate import Automate_RF_Register, updateStateMachine
from .models import RegistrationForm
from GestionMessagerie.models import Messagerie
from N3wtSchool import settings, bdd
from N3wtSchool.mailManager import sendMail, getConnection
from django.template.loader import render_to_string
import requests
import logging
logger = logging.getLogger(__name__)
@ -26,17 +28,82 @@ def send_notification(dossier):
# Changer l'état de l'automate
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
url = settings.URL_DJANGO + 'GestionMessagerie/message'
# Envoyer un email de relance aux responsables
try:
# Récupérer l'établissement du dossier
establishment_id = dossier.establishment.id
destinataires = dossier.eleve.profiles.all()
for destinataire in destinataires:
message = {
"objet": "[RELANCE]",
"destinataire" : destinataire.id,
"corpus": "RELANCE pour le dossier d'inscription"
# Obtenir la connexion SMTP pour cet établissement
connection = getConnection(establishment_id)
# Préparer le contenu de l'email
subject = f"[RELANCE] Dossier d'inscription en attente - {dossier.eleve.first_name} {dossier.eleve.last_name}"
context = {
'student_name': f"{dossier.eleve.first_name} {dossier.eleve.last_name}",
'deadline_date': (timezone.now() - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)).strftime('%d/%m/%Y'),
'establishment_name': dossier.establishment.name,
'base_url': settings.BASE_URL
}
response = requests.post(url, json=message)
# Utiliser un template HTML pour l'email (si disponible)
try:
html_message = render_to_string('emails/relance_signature.html', context)
except:
# Si pas de template, message simple
html_message = f"""
<html>
<body>
<h2>Relance - Dossier d'inscription en attente</h2>
<p>Bonjour,</p>
<p>Le dossier d'inscription de <strong>{context['student_name']}</strong> est en attente de signature depuis plus de {settings.EXPIRATION_DI_NB_DAYS} jours.</p>
<p>Merci de vous connecter à votre espace pour finaliser l'inscription.</p>
<p>Cordialement,<br>L'équipe {context['establishment_name']}</p>
</body>
</html>
"""
# Récupérer les emails des responsables
destinataires = []
profiles = dossier.eleve.profiles.all()
for profile in profiles:
if profile.email:
destinataires.append(profile.email)
if destinataires:
# Envoyer l'email
result = sendMail(
subject=subject,
message=html_message,
recipients=destinataires,
connection=connection
)
logger.info(f"Email de relance envoyé pour le dossier {dossier.id} à {destinataires}")
else:
logger.warning(f"Aucun email trouvé pour les responsables du dossier {dossier.id}")
except Exception as e:
logger.error(f"Erreur lors de l'envoi de l'email de relance pour le dossier {dossier.id}: {str(e)}")
# En cas d'erreur email, utiliser la messagerie interne comme fallback
try:
url = settings.URL_DJANGO + 'GestionMessagerie/send-message/'
# Créer ou récupérer une conversation avec chaque responsable
destinataires = dossier.eleve.profiles.all()
for destinataire in destinataires:
message_data = {
"conversation_id": None, # Sera géré par l'API
"sender_id": 1, # ID du système ou admin
"content": f"RELANCE pour le dossier d'inscription de {dossier.eleve.first_name} {dossier.eleve.last_name}"
}
response = requests.post(url, json=message_data)
if response.status_code != 201:
logger.error(f"Erreur lors de l'envoi du message interne: {response.text}")
except Exception as inner_e:
logger.error(f"Erreur lors de l'envoi du message interne de fallback: {str(inner_e)}")
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."

View File

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

View File

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

View File

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

View File

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Relance - Dossier d'inscription</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 3px solid #007bff;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #007bff;
margin: 0;
}
.alert {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
}
.alert-icon {
font-size: 20px;
margin-right: 10px;
}
.content {
margin: 20px 0;
}
.student-info {
background-color: #f8f9fa;
border-left: 4px solid #007bff;
padding: 15px;
margin: 20px 0;
}
.cta-button {
display: inline-block;
background-color: #007bff;
color: white;
padding: 12px 25px;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
font-size: 14px;
color: #6c757d;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{ establishment_name }}</h1>
<p>Relance - Dossier d'inscription</p>
</div>
<div class="alert">
<span class="alert-icon">⚠️</span>
<strong>Attention :</strong> Votre dossier d'inscription nécessite votre attention
</div>
<div class="content">
<p>Bonjour,</p>
<div class="student-info">
<h3>Dossier d'inscription de : <strong>{{ student_name }}</strong></h3>
<p>En attente depuis le : <strong>{{ deadline_date }}</strong></p>
</div>
<p>Nous vous informons que le dossier d'inscription mentionné ci-dessus est en attente de finalisation depuis plus de {{ deadline_date }}.</p>
<p><strong>Action requise :</strong></p>
<ul>
<li>Connectez-vous à votre espace personnel</li>
<li>Vérifiez les documents manquants</li>
<li>Complétez et signez les formulaires en attente</li>
</ul>
<div style="text-align: center;">
<a href="{{ base_url }}" class="cta-button">Accéder à mon espace</a>
</div>
<p>Si vous rencontrez des difficultés ou avez des questions concernant ce dossier, n'hésitez pas à nous contacter.</p>
</div>
<div class="footer">
<p>Cordialement,<br>
L'équipe {{ establishment_name }}</p>
<hr style="margin: 20px 0;">
<p style="font-size: 12px;">
Cet email a été envoyé automatiquement. Si vous pensez avoir reçu ce message par erreur,
veuillez contacter l'établissement directement.
</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

@ -24,7 +24,10 @@ from .views import (
)
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
from .views import registration_file_views, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
from .views import (
get_school_file_templates_by_rf,
get_parent_file_templates_by_rf
)
urlpatterns = [
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),

View File

@ -8,6 +8,9 @@ from N3wtSchool import renderers
from N3wtSchool import bdd
from io import BytesIO
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from django.core.files.base import ContentFile
from django.core.files import File
from pathlib import Path
import os
@ -16,13 +19,279 @@ from enum import Enum
import random
import string
from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger
from PyPDF2 import PdfMerger, PdfReader
from PyPDF2.errors import PdfReadError
import shutil
import logging
import json
from django.http import QueryDict
from rest_framework.response import Response
from rest_framework import status
logger = logging.getLogger(__name__)
def save_file_replacing_existing(file_field, filename, content, save=True):
"""
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
Args:
file_field: Le FileField Django (ex: registerForm.registration_file)
filename: Le nom du fichier à sauvegarder
content: Le contenu du fichier (File, BytesIO, ContentFile, etc.)
save: Si True, sauvegarde l'instance parente
"""
# Supprimer l'ancien fichier s'il existe
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
# Sauvegarder le nouveau fichier
file_field.save(filename, content, save=save)
def build_payload_from_request(request):
"""
Normalise la request en payload prêt à être donné au serializer.
- supporte multipart/form-data où le front envoie 'data' (JSON string) ou un fichier JSON + fichiers
- supporte application/json ou form-data simple
Retour: (payload_dict, None) ou (None, Response erreur)
"""
# Si c'est du JSON pur (Content-Type: application/json)
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
try:
# request.data contient déjà le JSON parsé par Django REST
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
logger.info(f"JSON payload extracted: {payload}")
return payload, None
except Exception as e:
logger.error(f'Error processing JSON: {e}')
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
# Cas multipart/form-data avec champ 'data'
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
if data_field:
try:
# Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder
if hasattr(data_field, 'read'):
raw = data_field.read()
if isinstance(raw, (bytes, bytearray)):
text = raw.decode('utf-8')
else:
text = raw
payload = json.loads(text)
# Si 'data' est bytes déjà
elif isinstance(data_field, (bytes, bytearray)):
payload = json.loads(data_field.decode('utf-8'))
# Si 'data' est une string JSON
elif isinstance(data_field, str):
payload = json.loads(data_field)
else:
# type inattendu
raise ValueError(f"Unsupported 'data' type: {type(data_field)}")
except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
logger.error(f'Invalid JSON in "data": {e}')
return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
else:
payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)
if isinstance(payload, QueryDict):
payload = payload.dict()
# Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité)
for f_key, f_val in request.FILES.items():
if f_key == 'data':
# remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr
try:
f_val.seek(0)
except Exception:
pass
# ne pas mettre le fichier 'data' dans le payload (c'est le JSON)
continue
payload[f_key] = f_val
return payload, None
def create_templates_for_registration_form(register_form):
"""
Idempotent:
- supprime les templates existants qui ne correspondent pas
aux masters du fileGroup courant du register_form (et supprime leurs fichiers).
- crée les templates manquants pour les masters du fileGroup courant.
Retourne la liste des templates créés.
"""
from Subscriptions.models import (
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
registration_school_file_upload_to,
)
created = []
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
# Récupérer les masters du fileGroup courant
current_group = getattr(register_form, "fileGroup", None)
if not current_group:
# Si plus de fileGroup, supprimer tous les templates existants pour ce RF
school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form)
for t in school_existing:
try:
if getattr(t, "file", None):
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
t.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
t.delete()
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
for t in parent_existing:
try:
if getattr(t, "file", None):
t.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
t.delete()
return created
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
school_master_ids = {m.pk for m in school_masters}
parent_master_ids = {m.pk for m in parent_masters}
# Supprimer les school templates obsolètes
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
if not tmpl.master_id or tmpl.master_id not in school_master_ids:
try:
if getattr(tmpl, "file", None):
tmpl.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None))
tmpl.delete()
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
# Supprimer les parent templates obsolètes
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
try:
if getattr(tmpl, "file", None):
tmpl.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
tmpl.delete()
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
for m in school_masters:
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
file_name = None
if m.file and hasattr(m.file, 'name') and m.file.name:
file_name = os.path.basename(m.file.name)
elif m.file:
file_name = str(m.file)
else:
try:
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
file_name = os.path.basename(pdf_file.name)
except Exception as e:
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
file_name = None
from django.core.files.base import ContentFile
upload_rel_path = registration_school_file_upload_to(
type("Tmp", (), {
"registration_form": register_form,
"establishment": getattr(register_form, "establishment", None),
"student": getattr(register_form, "student", None)
})(),
file_name
)
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
if tmpl:
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
master_file_changed = template_file_name != file_name
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
if master_file_changed or (
master_file_path and os.path.exists(master_file_path) and
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
):
# Supprimer l'ancien fichier du template (même si le nom change)
if tmpl.file and tmpl.file.name:
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
if os.path.exists(old_template_path):
try:
os.remove(old_template_path)
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
except Exception as e:
logger.error(f"Erreur suppression ancien fichier template: {e}")
# Copier le nouveau fichier du master (form existant)
if master_file_path and os.path.exists(master_file_path):
try:
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
import shutil
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
tmpl.file.name = upload_rel_path
tmpl.name = m.name or ""
tmpl.slug = slug
tmpl.formTemplateData = m.formMasterData or []
tmpl.save()
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
created.append(tmpl)
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
continue
# Sinon, création du template comme avant
tmpl = RegistrationSchoolFileTemplate(
master=m,
registration_form=register_form,
name=m.name or "",
formTemplateData=m.formMasterData or [],
slug=slug,
)
if file_name:
# Copier le fichier du master si besoin (form existant)
if master_file_path and not os.path.exists(abs_path):
try:
import shutil
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
tmpl.file.name = upload_rel_path
tmpl.save()
created.append(tmpl)
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
# Créer les parent templates manquants
for m in parent_masters:
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
if exists:
continue
tmpl = RegistrationParentFileTemplate.objects.create(
master=m,
registration_form=register_form,
file=None,
)
created.append(tmpl)
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
return created
def recupereListeFichesInscription():
"""
Retourne la liste complète des fiches dinscription.
@ -99,12 +368,70 @@ def getArgFromRequest(_argument, _request):
def merge_files_pdf(file_paths):
"""
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
Les fichiers invalides sont ignorés avec un log d'erreur.
"""
merger = PdfMerger()
files_added = 0
# Extensions d'images supportées
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
# Ajouter les fichiers valides au merger
for file_path in file_paths:
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
merged_pdf = BytesIO()
@ -133,25 +460,11 @@ def rfToPDF(registerForm, filename):
if not pdf:
raise ValueError("Erreur lors de la génération du PDF.")
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
if registerForm.registration_file and registerForm.registration_file.name:
# Vérifiez si le chemin est déjà absolu ou relatif
if os.path.isabs(registerForm.registration_file.name):
existing_file_path = registerForm.registration_file.name
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
# Vérifier si le fichier existe et le supprimer
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
registerForm.registration_file.delete(save=False)
else:
print(f'File does not exist: {existing_file_path}')
# Enregistrer directement le fichier dans le champ registration_file
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
try:
registerForm.registration_file.save(
os.path.basename(filename), # Utiliser uniquement le nom de fichier
save_file_replacing_existing(
registerForm.registration_file,
os.path.basename(filename),
File(BytesIO(pdf.content)),
save=True
)
@ -212,4 +525,57 @@ def getHistoricalYears(count=5):
historical_start_year = start_year - i
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
return historical_years
return historical_years
def generate_form_json_pdf(register_form, form_json):
"""
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
et l'associe au RegistrationSchoolFileTemplate.
Le PDF contient le titre, les labels et types de champs.
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
"""
# Récupérer le nom du formulaire
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
filename = f"{form_name}.pdf"
# Générer le PDF
buffer = BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
y = 800
# Titre
c.setFont("Helvetica-Bold", 20)
c.drawString(100, y, form_json.get("title", "Formulaire"))
y -= 40
# Champs
c.setFont("Helvetica", 12)
fields = form_json.get("fields", [])
for field in fields:
label = field.get("label", field.get("id", ""))
ftype = field.get("type", "")
value = field.get("value", "")
# Afficher la valeur si elle existe
if value not in (None, ""):
c.drawString(100, y, f"{label} [{ftype}] : {value}")
else:
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25
if y < 100:
c.showPage()
y = 800
c.save()
buffer.seek(0)
pdf_content = buffer.read()
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
register_form.registration_file.delete(save=False)
# Retourner le ContentFile avec uniquement le nom du fichier
return ContentFile(pdf_content, name=os.path.basename(filename))

View File

@ -1,11 +1,25 @@
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
from .registration_file_views import (
from .register_form_views import (
RegisterFormView,
RegisterFormWithIdView,
send,
resend,
archive,
get_school_file_templates_by_rf,
get_parent_file_templates_by_rf
)
from .registration_school_file_masters_views import (
RegistrationSchoolFileMasterView,
RegistrationSchoolFileMasterSimpleView,
RegistrationSchoolFileMasterSimpleView,
)
from .registration_school_file_templates_views import (
RegistrationSchoolFileTemplateView,
RegistrationSchoolFileTemplateSimpleView,
RegistrationSchoolFileTemplateSimpleView
)
from .registration_parent_file_masters_views import (
RegistrationParentFileMasterView,
RegistrationParentFileMasterSimpleView,
RegistrationParentFileMasterSimpleView
)
from .registration_parent_file_templates_views import (
RegistrationParentFileTemplateSimpleView,
RegistrationParentFileTemplateView
)
@ -33,7 +47,7 @@ __all__ = [
'RegistrationFileGroupSimpleView',
'get_registration_files_by_group',
'get_school_file_templates_by_rf',
'get_parent_file_templates_by_rf'
'get_parent_file_templates_by_rf',
'StudentView',
'StudentListView',
'ChildrenListView',

View File

@ -9,6 +9,7 @@ from drf_yasg import openapi
import json
import os
import threading
from django.core.files import File
import N3wtSchool.mailManager as mailer
@ -17,10 +18,10 @@ import Subscriptions.util as util
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.pagination import CustomSubscriptionPagination
from Subscriptions.models import (
Guardian,
RegistrationForm,
RegistrationSchoolFileTemplate,
RegistrationFileGroup,
Guardian,
RegistrationForm,
RegistrationSchoolFileTemplate,
RegistrationFileGroup,
RegistrationParentFileTemplate,
StudentCompetency
)
@ -323,6 +324,27 @@ class RegisterFormWithIdView(APIView):
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
registerForm.save()
# Envoi du mail d'inscription au second guardian si besoin
guardians = registerForm.student.guardians.all()
from Auth.models import Profile
from N3wtSchool.mailManager import sendRegisterForm
for guardian in guardians:
# Recherche de l'email dans le profil lié au guardian (si existant)
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
# Fallback sur le champ email direct (si jamais il existe)
if not email:
email = getattr(guardian, "email", None)
logger.debug(f"[RF_UNDER_REVIEW] Guardian id={guardian.id}, email={email}")
if email:
profile_exists = Profile.objects.filter(email=email).exists()
logger.debug(f"[RF_UNDER_REVIEW] Profile existe pour {email} ? {profile_exists}")
if not profile_exists:
logger.debug(f"[RF_UNDER_REVIEW] Envoi du mail d'inscription à {email} pour l'établissement {registerForm.establishment.pk}")
sendRegisterForm(email, registerForm.establishment.pk)
# Mise à jour de l'automate
# Vérification de la présence du fichier SEPA
if registerForm.sepa_file:
@ -332,9 +354,32 @@ class RegisterFormWithIdView(APIView):
# Mise à jour de l'automate pour une signature classique
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
except Exception as e:
logger.error(f"[RF_UNDER_REVIEW] Exception: {e}")
import traceback
logger.error(traceback.format_exc())
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus aux responsables légaux
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = registerForm.notes or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
updateStateMachine(registerForm, 'EVENT_REFUSE')
util.delete_registration_files(registerForm)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
@ -377,14 +422,23 @@ class RegisterFormWithIdView(APIView):
fileNames.extend(parent_file_templates)
# Création du fichier PDF fusionné
merged_pdf_content = 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é
registerForm.fusion_file.save(
f"dossier_complet.pdf",
File(merged_pdf_content),
save=True
)
# Mise à jour du champ fusion_file avec le fichier fusionné
util.save_file_replacing_existing(
registerForm.fusion_file,
"dossier_complet.pdf",
File(merged_pdf_content),
save=True
)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
finally:
# Libérer explicitement la mémoire du BytesIO
if merged_pdf_content is not None:
merged_pdf_content.close()
# Valorisation des StudentCompetency pour l'élève
try:
student = registerForm.student
@ -426,11 +480,324 @@ class RegisterFormWithIdView(APIView):
except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
def send_validation_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
class_name = None
if student.associated_class:
class_name = student.associated_class.atmosphere_name
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_validation_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_VALIDATE')
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
def send_refus_definitif_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = data.get('notes', '') or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_refus_definitif_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
# Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
}
),
responses={200: RegistrationFormSerializer()},
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
operation_summary="Auto-sauvegarder un dossier d'inscription"
)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
def patch(self, request, id):
"""
Auto-sauvegarde partielle d'un dossier d'inscription.
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
"""
try:
# Récupérer le dossier d'inscription
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
if not registerForm:
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Préparer les données à mettre à jour
update_data = {}
# Traiter les données étudiant si présentes
if 'student_data' in request.data:
try:
student_data = json.loads(request.data['student_data'])
# Extraire les données de paiement des données étudiant
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
payment_data = {}
for field in payment_fields:
if field in student_data:
payment_data[field] = student_data.pop(field)
# Si nous avons des données de paiement, les traiter
if payment_data:
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
# Traiter les données de paiement
payment_updates = {}
# Gestion du mode de paiement d'inscription
if 'registration_payment' in payment_data and payment_data['registration_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
registerForm.registration_payment = payment_mode
payment_updates['registration_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
# Gestion du mode de paiement de scolarité
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
registerForm.tuition_payment = payment_mode
payment_updates['tuition_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
# Gestion du plan de paiement d'inscription
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
registerForm.registration_payment_plan = payment_plan
payment_updates['registration_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
# Gestion du plan de paiement de scolarité
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
registerForm.tuition_payment_plan = payment_plan
payment_updates['tuition_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
# Sauvegarder les modifications de paiement
if payment_updates:
registerForm.save()
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
update_data['student'] = student_data
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in student_data")
# Traiter les données des responsables si présentes
if 'guardians_data' in request.data:
try:
guardians_data = json.loads(request.data['guardians_data'])
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
# Enregistrer directement chaque guardian avec le modèle
for i, guardian_data in enumerate(guardians_data):
guardian_id = guardian_data.get('id')
if guardian_id:
try:
# Récupérer le guardian existant et mettre à jour ses champs
guardian = Guardian.objects.get(id=guardian_id)
# Mettre à jour les champs si ils sont présents
if 'birth_date' in guardian_data and guardian_data['birth_date']:
guardian.birth_date = guardian_data['birth_date']
if 'profession' in guardian_data:
guardian.profession = guardian_data['profession']
if 'address' in guardian_data:
guardian.address = guardian_data['address']
if 'phone' in guardian_data:
guardian.phone = guardian_data['phone']
if 'first_name' in guardian_data:
guardian.first_name = guardian_data['first_name']
if 'last_name' in guardian_data:
guardian.last_name = guardian_data['last_name']
guardian.save()
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
except Guardian.DoesNotExist:
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in guardians_data")
# Traiter les données de la fratrie si présentes
if 'siblings_data' in request.data:
try:
siblings_data = json.loads(request.data['siblings_data'])
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
# Enregistrer directement chaque sibling avec le modèle
for i, sibling_data in enumerate(siblings_data):
sibling_id = sibling_data.get('id')
if sibling_id:
try:
# Récupérer le sibling existant et mettre à jour ses champs
from Subscriptions.models import Sibling
sibling = Sibling.objects.get(id=sibling_id)
# Mettre à jour les champs si ils sont présents
if 'first_name' in sibling_data:
sibling.first_name = sibling_data['first_name']
if 'last_name' in sibling_data:
sibling.last_name = sibling_data['last_name']
if 'birth_date' in sibling_data and sibling_data['birth_date']:
sibling.birth_date = sibling_data['birth_date']
sibling.save()
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
except Sibling.DoesNotExist:
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in siblings_data")
# Traiter les données de paiement si présentes
if 'payment_data' in request.data:
try:
payment_data = json.loads(request.data['payment_data'])
logger.debug(f"Auto-save: payment_data = {payment_data}")
# Mettre à jour directement les champs de paiement du formulaire
payment_updates = {}
# Gestion du mode de paiement d'inscription
if 'registration_payment' in payment_data and payment_data['registration_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
registerForm.registration_payment = payment_mode
payment_updates['registration_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
# Gestion du mode de paiement de scolarité
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
registerForm.tuition_payment = payment_mode
payment_updates['tuition_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
# Gestion du plan de paiement d'inscription
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
registerForm.registration_payment_plan = payment_plan
payment_updates['registration_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
# Gestion du plan de paiement de scolarité
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
registerForm.tuition_payment_plan = payment_plan
payment_updates['tuition_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
# Sauvegarder les modifications de paiement
if payment_updates:
registerForm.save()
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in payment_data")
# Mettre à jour la page actuelle si présente
if 'current_page' in request.data:
try:
current_page = int(request.data['current_page'])
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
logger.debug(f"Auto-save: current_page = {current_page}")
except (ValueError, TypeError):
logger.warning("Auto-save: Invalid current_page value")
# Effectuer la mise à jour partielle seulement si nous avons des données
if update_data:
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
if serializer.is_valid():
serializer.save()
logger.debug(f"Auto-save successful for student {id}")
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
else:
logger.warning(f"Auto-save validation errors: {serializer.errors}")
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
else:
# Pas de données à sauvegarder, mais on retourne un succès
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
except Exception as e:
logger.error(f"Auto-save error for student {id}: {str(e)}")
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
@swagger_auto_schema(
responses={204: 'No Content'},
operation_description="Supprime un dossier d'inscription donné.",

View File

@ -1,372 +0,0 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
from N3wtSchool import bdd
class RegistrationSchoolFileMasterView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les masters liés à l'établissement via groups.establishment
masters = RegistrationSchoolFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau master de template d'inscription",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
201: RegistrationSchoolFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationSchoolFileMasterSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileMasterSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un master de template d'inscription spécifique",
responses={
200: RegistrationSchoolFileMasterSerializer,
404: "Master non trouvé"
}
)
def get(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileMasterSerializer(master)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un master de template d'inscription existant",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
200: RegistrationSchoolFileMasterSerializer,
400: "Données invalides",
404: "Master non trouvé"
}
)
def put(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileMasterSerializer(master, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un master de template d'inscription",
responses={
204: "Suppression réussie",
404: "Master non trouvé"
}
)
def delete(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is not None:
master.delete()
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationSchoolFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates liés à l'établissement via master.groups.establishment
templates = RegistrationSchoolFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationSchoolFileTemplateSerializer,
responses={
201: RegistrationSchoolFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationSchoolFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationSchoolFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationSchoolFileTemplateSerializer,
responses={
200: RegistrationSchoolFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileMasterView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les fichiers parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les fichiers parents liés à l'établissement
templates = RegistrationParentFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau fichier parent",
request_body=RegistrationParentFileMasterSerializer,
responses={
201: RegistrationParentFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileMasterSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileMasterSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un fichier parent spécifique",
responses={
200: RegistrationParentFileMasterSerializer,
404: "Fichier parent non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un fichier parent existant",
request_body=RegistrationParentFileMasterSerializer,
responses={
200: RegistrationParentFileMasterSerializer,
400: "Données invalides",
404: "Fichier parent non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Fichier parent mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un fichier parent",
responses={
204: "Suppression réussie",
404: "Fichier parent non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
templates = RegistrationParentFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationParentFileTemplateSerializer,
responses={
201: RegistrationParentFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationParentFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationParentFileTemplateSerializer,
responses={
200: RegistrationParentFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -0,0 +1,177 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
from Subscriptions.models import (
RegistrationForm,
RegistrationParentFileMaster
)
from N3wtSchool import bdd
import logging
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationParentFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère tous les fichiers parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les fichiers parents liés à l'établissement
templates = RegistrationParentFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau fichier parent",
request_body=RegistrationParentFileMasterSerializer,
responses={
201: RegistrationParentFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
logger.info(f"raw request.data: {request.data}")
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for serializer: {payload}")
serializer = RegistrationParentFileMasterSerializer(data=payload, partial=True)
if serializer.is_valid():
obj = serializer.save()
# Propager la création des templates côté serveur pour les RegistrationForm
try:
groups_qs = obj.groups.all()
if groups_qs.exists():
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
for rf in rfs:
try:
util.create_templates_for_registration_form(rf)
except Exception as e:
logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None))
return Response(RegistrationParentFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
logger.error(f"serializer errors: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileMasterSimpleView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère un fichier parent spécifique",
responses={
200: RegistrationParentFileMasterSerializer,
404: "Fichier parent non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un fichier parent existant",
request_body=RegistrationParentFileMasterSerializer,
responses={
200: RegistrationParentFileMasterSerializer,
400: "Données invalides",
404: "Fichier parent non trouvé"
}
)
def put(self, request, id):
master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
# snapshot des groups avant update
old_group_ids = set(master.groups.values_list('id', flat=True))
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for update serializer: {payload}")
serializer = RegistrationParentFileMasterSerializer(master, data=payload, partial=True)
if serializer.is_valid():
obj = serializer.save()
# groups après update
new_group_ids = set(obj.groups.values_list('id', flat=True))
removed_group_ids = old_group_ids - new_group_ids
added_group_ids = new_group_ids - old_group_ids
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
if removed_group_ids:
try:
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
for rf in rfs_removed:
try:
util.create_templates_for_registration_form(rf)
except Exception as e:
logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None))
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
if added_group_ids:
try:
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
for rf in rfs_added:
try:
util.create_templates_for_registration_form(rf)
except Exception as e:
logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None))
return Response(serializer.data, status=status.HTTP_200_OK)
logger.error(f"serializer errors on put: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un fichier parent",
responses={
204: "Suppression réussie",
404: "Fichier parent non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -0,0 +1,111 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from Subscriptions.serializers import RegistrationParentFileTemplateSerializer
from Subscriptions.models import (
RegistrationParentFileTemplate
)
from N3wtSchool import bdd
import logging
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
templates = RegistrationParentFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationParentFileTemplateSerializer,
responses={
201: RegistrationParentFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationParentFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationParentFileTemplateSerializer,
responses={
200: RegistrationParentFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé
if template.file and template.file.name:
template.file.delete(save=False)
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -0,0 +1,190 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
from Subscriptions.models import (
RegistrationForm,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate
)
from N3wtSchool import bdd
import logging
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationSchoolFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les masters liés à l'établissement via groups.establishment
masters = RegistrationSchoolFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau master de template d'inscription",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
201: RegistrationSchoolFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
logger.info(f"raw request.data: {request.data}")
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
if serializer.is_valid():
obj = serializer.save()
# Propager la création des templates côté serveur pour les RegistrationForm
try:
groups_qs = obj.groups.all()
if groups_qs.exists():
# Tous les RegistrationForm dont fileGroup est dans les groups du master
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
for rf in rfs:
try:
util.create_templates_for_registration_form(rf)
except Exception as e:
logger.exception("Error creating templates for RF %s from master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while propagating templates after master creation %s", getattr(obj, 'pk', None))
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
logger.error(f"serializer errors: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileMasterSimpleView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère un master de template d'inscription spécifique",
responses={
200: RegistrationSchoolFileMasterSerializer,
404: "Master non trouvé"
}
)
def get(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileMasterSerializer(master)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un master de template d'inscription existant",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
200: RegistrationSchoolFileMasterSerializer,
400: "Données invalides",
404: "Master non trouvé"
}
)
def put(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
# snapshot des groups avant update
old_group_ids = set(master.groups.values_list('id', flat=True))
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for update serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
if serializer.is_valid():
obj = serializer.save()
# groups après update
new_group_ids = set(obj.groups.values_list('id', flat=True))
removed_group_ids = old_group_ids - new_group_ids
added_group_ids = new_group_ids - old_group_ids
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
if removed_group_ids:
logger.info("REMOVE IDs")
try:
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
for rf in rfs_removed:
try:
util.create_templates_for_registration_form(rf) # supprimera les templates obsolètes
except Exception as e:
logger.exception("Error cleaning templates for RF %s after master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while processing RFs for removed groups after master update %s", getattr(obj, 'pk', None))
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
if added_group_ids:
logger.info("ADD IDs")
try:
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
for rf in rfs_added:
try:
util.create_templates_for_registration_form(rf) # créera les templates manquants
except Exception as e:
logger.exception("Error creating templates for RF %s after master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
except Exception:
logger.exception("Error while processing RFs for added groups after master update %s", getattr(obj, 'pk', None))
return Response(serializer.data, status=status.HTTP_200_OK)
logger.error(f"serializer errors on put: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un master de template d'inscription",
responses={
204: "Suppression réussie",
404: "Master non trouvé"
}
)
def delete(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is not None:
# Supprimer tous les templates liés et leurs fichiers PDF
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
for template in templates:
if template.file and template.file.name:
template.file.delete(save=False)
template.delete()
master.delete()
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
else:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -0,0 +1,191 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
import os
import glob
from Subscriptions.serializers import RegistrationSchoolFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileTemplate
from N3wtSchool import bdd
import logging
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationSchoolFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates liés à l'établissement via master.groups.establishment
templates = RegistrationSchoolFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationSchoolFileTemplateSerializer,
responses={
201: RegistrationSchoolFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationSchoolFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileTemplateSimpleView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationSchoolFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationSchoolFileTemplateSerializer,
responses={
200: RegistrationSchoolFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
# Normaliser la payload (support form-data avec champ 'data' JSON ou fichier JSON)
payload, resp = util.build_payload_from_request(request)
if resp is not None:
return resp
# Synchroniser fields[].value dans le payload AVANT le serializer (pour les formulaires dynamiques)
formTemplateData = payload.get('formTemplateData')
if formTemplateData and isinstance(formTemplateData, dict):
responses = None
if "responses" in formTemplateData:
resp = formTemplateData["responses"]
if isinstance(resp, dict) and "responses" in resp:
responses = resp["responses"]
elif isinstance(resp, dict):
responses = resp
if responses and "fields" in formTemplateData:
for field in formTemplateData["fields"]:
field_id = field.get("id")
if field_id and field_id in responses:
field["value"] = responses[field_id]
payload['formTemplateData'] = formTemplateData
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
# Cas 1 : Upload d'un fichier existant (PDF/image)
if 'file' in request.FILES:
upload = request.FILES['file']
file_field = template.file
upload_name = upload.name
upload_dir = os.path.dirname(file_field.path) if file_field and file_field.name else None
if upload_dir:
base_name, _ = os.path.splitext(upload_name)
pattern = os.path.join(upload_dir, f"{base_name}.*")
for f in glob.glob(pattern):
try:
if os.path.exists(f):
os.remove(f)
logger.info(f"Suppression du fichier existant (pattern): {f}")
except Exception as e:
logger.error(f"Erreur suppression fichier existant (pattern): {e}")
target_path = os.path.join(upload_dir, upload_name)
if os.path.exists(target_path):
try:
os.remove(target_path)
except Exception as e:
logger.error(f"Erreur suppression fichier cible: {e}")
# On écrase le fichier existant sans passer par le serializer
template.file.save(upload_name, upload, save=True)
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
# Cas 2 : Formulaire dynamique (JSON)
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
if serializer.is_valid():
serializer.save()
# Régénérer le PDF si besoin
formTemplateData = serializer.validated_data.get('formTemplateData')
if (
formTemplateData
and isinstance(formTemplateData, dict)
and formTemplateData.get("fields")
and hasattr(template, "file")
):
old_pdf_name = None
if template.file and template.file.name:
old_pdf_name = os.path.basename(template.file.name)
try:
template.file.delete(save=False)
if os.path.exists(template.file.path):
os.remove(template.file.path)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
from Subscriptions.util import generate_form_json_pdf
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
template.file.save(pdf_filename, pdf_file, save=True)
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé
if template.file and template.file.name:
file_path = template.file.path
template.file.delete(save=False)
# Vérification post-suppression
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.info(f"Fichier supprimé manuellement: {file_path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

Binary file not shown.

View File

@ -12,21 +12,32 @@ def run_command(command):
return process.returncode
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
collect_static_cmd = [
["python", "manage.py", "collectstatic", "--noinput"]
]
flush_data_cmd = [
["python", "manage.py", "flush", "--noinput"]
]
migrate_commands = [
["python", "manage.py", "makemigrations", "Common", "--noinput"],
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
["python", "manage.py", "makemigrations", "School", "--noinput"]
]
commands = [
["python", "manage.py", "collectstatic", "--noinput"],
#["python", "manage.py", "flush", "--noinput"],
# ["python", "manage.py", "makemigrations", "Common", "--noinput"],
# ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
# ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
# ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
# ["python", "manage.py", "makemigrations", "Planning", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
# ["python", "manage.py", "makemigrations", "Auth", "--noinput"],
# ["python", "manage.py", "makemigrations", "School", "--noinput"],
["python", "manage.py", "migrate", "--noinput"]
]
@ -45,14 +56,29 @@ def run_daphne():
return 0
if __name__ == "__main__":
for command in collect_static_cmd:
if run_command(command) != 0:
exit(1)
if flush_data:
for command in flush_data_cmd:
if run_command(command) != 0:
exit(1)
if migrate_data:
for command in migrate_commands:
if run_command(command) != 0:
exit(1)
for command in commands:
if run_command(command) != 0:
exit(1)
#if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
if test_mode:
for test_command in test_commands:
if run_command(test_command) != 0:
exit(1)
if watch_mode:
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])

View File

@ -42,14 +42,9 @@ const nextConfig = {
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY,
},
async rewrites() {
return [
{
source: '/api/documents/:path*',
destination: 'https://api.docuseal.com/v1/documents/:path*',
},
{
source: '/api/auth/:path*',
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy

View File

@ -8,7 +8,6 @@
"name": "n3wt-school-front-end",
"version": "0.0.3",
"dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0",
@ -537,11 +536,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@docuseal/react": {
"version": "1.0.66",
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -11269,11 +11263,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@docuseal/react": {
"version": "1.0.66",
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
},
"@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",

View File

@ -14,7 +14,6 @@
"test:coverage": "jest --coverage"
},
"dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0",

View File

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

View File

@ -36,7 +36,6 @@ export default function DashboardPage() {
const {
selectedEstablishmentId,
selectedEstablishmentTotalCapacity,
apiDocuseal,
} = useEstablishment();
const [statusDistribution, setStatusDistribution] = useState([
@ -165,25 +164,6 @@ export default function DashboardPage() {
return (
<div key={selectedEstablishmentId} className="p-6">
<div className="flex items-center gap-3 mb-6">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
apiDocuseal
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{apiDocuseal ? (
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
) : (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{apiDocuseal
? 'Clé API Docuseal renseignée'
: 'Clé API Docuseal manquante'}
</span>
</div>
{/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard

View File

@ -4,7 +4,7 @@ import Tab from '@/components/Tab';
import TabContent from '@/components/TabContent';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import CheckBox from '@/components/CheckBox'; // Import du composant CheckBox
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
import logger from '@/utils/logger';
import {
fetchSmtpSettings,

View File

@ -10,7 +10,7 @@ import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext';
import Button from '@/components/Form/Button';
import SelectChoice from '@/components/Form/SelectChoice';
import CheckBox from '@/components/CheckBox';
import CheckBox from '@/components/Form/CheckBox';
import {
fetchAbsences,
createAbsences,

View File

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

View File

@ -10,7 +10,7 @@ import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import SectionTitle from '@/components/SectionTitle';
import InputPhone from '@/components/Form/InputPhone';
import CheckBox from '@/components/CheckBox';
import CheckBox from '@/components/Form/CheckBox';
import RadioList from '@/components/Form/RadioList';
import SelectChoice from '@/components/Form/SelectChoice';
import Loader from '@/components/Loader';
@ -34,10 +34,7 @@ import {
import {
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters,
cloneTemplate,
createRegistrationSchoolFileTemplate,
createRegistrationParentFileTemplate,
fetchRegistrationParentFileMasters
} from '@/app/actions/registerFileGroupAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';
@ -96,7 +93,7 @@ export default function CreateSubscriptionPage() {
const { getNiveauLabel } = useClasses();
const formDataRef = useRef(formData);
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken();
const router = useRouter();
@ -522,128 +519,23 @@ export default function CreateSubscriptionPage() {
} else {
// Création du dossier d'inscription
createRegisterForm(data, csrfToken)
.then((data) => {
// Clonage des schoolFileTemplates
const masters = schoolFileMasters.filter((file) =>
file.groups.includes(selectedFileGroup)
.then((response) => {
showNotification(
"Dossier d'inscription créé avec succès",
'success',
'Succès'
);
const parentMasters = parentFileMasters.filter((file) =>
file.groups.includes(selectedFileGroup)
);
const clonePromises = masters.map((templateMaster) =>
cloneTemplate(
templateMaster.id,
formDataRef.current.guardianEmail,
templateMaster.is_required,
selectedEstablishmentId,
apiDocuseal
)
.then((clonedDocument) => {
const cloneData = {
name: `${templateMaster.name}_${formDataRef.current.studentFirstName}_${formDataRef.current.studentLastName}`,
slug: clonedDocument.slug,
id: clonedDocument.id,
master: templateMaster.id,
registration_form: data.student.id,
};
return createRegistrationSchoolFileTemplate(
cloneData,
csrfToken
)
.then((response) =>
logger.debug('Template enregistré avec succès:', response)
)
.catch((error) => {
setIsLoading(false);
logger.error(
"Erreur lors de l'enregistrement du template:",
error
);
showNotification(
"Erreur lors de la création du dossier d'inscription",
'error',
'Erreur',
'ERR_ADM_SUB_03'
);
});
})
.catch((error) => {
setIsLoading(false);
logger.error('Error during cloning or sending:', error);
showNotification(
"Erreur lors de la création du dossier d'inscription",
'error',
'Erreur',
'ERR_ADM_SUB_05'
);
})
);
// Clonage des parentFileTemplates
const parentClonePromises = parentMasters.map((parentMaster) => {
const parentTemplateData = {
master: parentMaster.id,
registration_form: data.student.id,
};
return createRegistrationParentFileTemplate(
parentTemplateData,
csrfToken
)
.then((response) =>
logger.debug(
'Parent template enregistré avec succès:',
response
)
)
.catch((error) => {
setIsLoading(false);
logger.error(
"Erreur lors de l'enregistrement du parent template:",
error
);
showNotification(
"Erreur lors de la création du dossier d'inscription",
'error',
'Erreur',
'ERR_ADM_SUB_02'
);
});
});
// Attendre que tous les clones soient créés
Promise.all([...clonePromises, ...parentClonePromises])
.then(() => {
// Redirection après succès
showNotification(
"Dossier d'inscription créé avec succès",
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
})
.catch((error) => {
setIsLoading(false);
showNotification(
"Erreur lors de la création du dossier d'inscription",
'error',
'Erreur',
'ERR_ADM_SUB_04'
);
logger.error('Error during cloning or sending:', error);
});
})
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
})
.catch((error) => {
setIsLoading(false);
logger.error('Erreur lors de la mise à jour du dossier:', error);
showNotification(
"Erreur lors de la création du dossier d'inscription",
'error',
'Erreur',
'ERR_ADM_SUB_01'
);
logger.error('Error during register form creation:', error);
});
}
};

View File

@ -19,7 +19,7 @@ export default function Page() {
const [formErrors, setFormErrors] = useState({});
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => {
@ -59,7 +59,6 @@ export default function Page() {
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
errors={formErrors}

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import React from 'react';
import Button from '@/components/Form/Button';
import Logo from '@/components/Logo'; // Import du composant Logo
import FormRenderer from '@/components/Form/FormRenderer';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
export default function Home() {
const t = useTranslations('homePage');
@ -14,7 +15,6 @@ export default function Home() {
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
<p className="text-lg mb-8">{t('pleaseLogin')}</p>
<Button text={t('loginButton')} primary href="/users/login" />
<FormRenderer />
</div>
);
}

View File

@ -17,7 +17,7 @@ export default function Page() {
const enable = searchParams.get('enabled') === 'true';
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => {
@ -53,7 +53,6 @@ export default function Page() {
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_PARENTS_HOME_URL}
enable={enable}

View File

@ -3,11 +3,7 @@ import {
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL,
FE_API_DOCUSEAL_CLONE_URL,
FE_API_DOCUSEAL_DOWNLOAD_URL,
FE_API_DOCUSEAL_GENERATE_TOKEN,
FE_API_DOCUSEAL_DELETE_URL
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
@ -104,12 +100,12 @@ export async function createRegistrationFileGroup(groupData, csrfToken) {
}
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
// Toujours FormData, jamais JSON
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
method: 'POST',
body: JSON.stringify(data),
body: data,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
@ -190,10 +186,9 @@ export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{
method: 'PUT',
body: JSON.stringify(data),
body: data,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}
@ -327,62 +322,3 @@ export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
}
);
};
// API requests
export const removeTemplate = (templateId, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_DELETE_URL}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const cloneTemplate = (templateId, email, is_required, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_CLONE_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId,
email,
is_required,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) => {
const url = `${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}?establishment_id=${selectedEstablishmentId}&apiDocuseal=${apiDocuseal}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const generateToken = (email, id = null, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_GENERATE_TOKEN}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_email: email, id, establishment_id :selectedEstablishmentId, apiDocuseal }),
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -6,10 +6,13 @@ import {
BE_SUBSCRIPTION_ABSENCES_URL,
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
} from '@/utils/Url';
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import logger from '@/utils/logger';
export const editStudentCompetencies = (data, csrfToken) => {
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
@ -83,6 +86,45 @@ export const editRegisterForm = (id, data, csrfToken) => {
.catch(errorHandler);
};
export const autoSaveRegisterForm = async (id, data, csrfToken) => {
try {
// Version allégée pour auto-save - ne pas envoyer tous les fichiers
const autoSaveData = new FormData();
// Ajouter seulement les données textuelles pour l'auto-save
if (data.student) {
autoSaveData.append('student_data', JSON.stringify(data.student));
}
if (data.guardians) {
autoSaveData.append('guardians_data', JSON.stringify(data.guardians));
}
if (data.siblings) {
autoSaveData.append('siblings_data', JSON.stringify(data.siblings));
}
if (data.currentPage) {
autoSaveData.append('current_page', data.currentPage);
}
autoSaveData.append('auto_save', 'true');
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
headers: {
'X-CSRFToken': csrfToken,
},
body: autoSaveData,
credentials: 'include',
})
.then(requestResponseHandler)
.catch(() => {
// Silent fail pour l'auto-save
logger.debug('Auto-save failed silently');
});
} catch (error) {
// Silent fail pour l'auto-save
logger.debug('Auto-save error:', error);
}
};
export const createRegisterForm = (data, csrfToken) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
return fetch(url, {
@ -302,3 +344,68 @@ export const deleteAbsences = (id, csrfToken) => {
credentials: 'include',
});
};
/**
* Récupère les formulaires maîtres d'inscription pour un établissement
* @param {number} establishmentId - ID de l'établissement
* @returns {Promise<Array>} Liste des formulaires
*/
export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Sauvegarde les réponses d'un formulaire dans RegistrationSchoolFileTemplate
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
* @param {Object} formTemplateData - Données du formulaire à sauvegarder
* @param {string} csrfToken - Token CSRF
* @returns {Promise} Résultat de la sauvegarde
*/
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
const payload = {
formTemplateData: formTemplateData,
};
return fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(payload),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Récupère les données sauvegardées d'un RegistrationSchoolFileTemplate
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
* @returns {Promise<Object>} Template avec formTemplateData
*/
export const fetchFormResponses = (templateId) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -0,0 +1,63 @@
'use client';
import React from 'react';
import { CheckCircle } from 'lucide-react';
/**
* Composant indicateur de sauvegarde automatique
* @param {Boolean} isSaving - Si la sauvegarde est en cours
* @param {Date} lastSaved - Date de la dernière sauvegarde
* @param {Boolean} autoSaveEnabled - Si l'auto-save est activée
* @param {Function} onToggleAutoSave - Callback pour activer/désactiver l'auto-save
*/
export default function AutoSaveIndicator({
isSaving = false,
lastSaved = null,
autoSaveEnabled = true,
onToggleAutoSave = null,
}) {
if (!autoSaveEnabled && !lastSaved && !isSaving) {
return null;
}
return (
<div className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center space-x-2">
{isSaving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium">
Sauvegarde en cours...
</span>
</>
) : lastSaved ? (
<>
<CheckCircle className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600">
Sauvegardé à {lastSaved.toLocaleTimeString()}
</span>
</>
) : (
<span className="text-sm text-gray-500">Auto-sauvegarde activée</span>
)}
</div>
{onToggleAutoSave && (
<button
onClick={onToggleAutoSave}
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
autoSaveEnabled
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
title={
autoSaveEnabled
? 'Désactiver la sauvegarde automatique'
: 'Activer la sauvegarde automatique'
}
>
{autoSaveEnabled ? '✓ Auto-save' : '○ Auto-save'}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,714 @@
import React, { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import InputTextIcon from './InputTextIcon';
import SelectChoice from './SelectChoice';
import Button from './Button';
import IconSelector from './IconSelector';
import * as LucideIcons from 'lucide-react';
import { FIELD_TYPES } from './FormTypes';
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
export default function AddFieldModal({
isOpen,
onClose,
onSubmit,
editingField = null,
editingIndex = -1,
}) {
const isEditing = editingIndex >= 0;
const [currentField, setCurrentField] = useState({
id: '',
label: '',
type: 'text',
required: false,
icon: '',
options: [],
text: '',
placeholder: '',
acceptTypes: '',
maxSize: 5, // 5MB par défaut
checked: false,
validation: {
pattern: '',
minLength: '',
maxLength: '',
},
});
const [showIconPicker, setShowIconPicker] = useState(false);
const [newOption, setNewOption] = useState('');
const { control, handleSubmit, reset, setValue } = useForm();
// Mettre à jour l'état et les valeurs du formulaire lorsque editingField change
useEffect(() => {
if (isOpen) {
const defaultValues = editingField || {
id: '',
label: '',
type: 'text',
required: false,
icon: '',
options: [],
text: '',
placeholder: '',
acceptTypes: '',
maxSize: 5,
checked: false,
signatureData: '',
backgroundColor: '#ffffff',
penColor: '#000000',
penWidth: 2,
validation: {
pattern: '',
minLength: '',
maxLength: '',
},
};
// Si un type a été présélectionné depuis le sélecteur de type
if (editingField && !isEditing) {
// S'assurer que le type est correctement défini
if (typeof editingField.type === 'string') {
defaultValues.type = editingField.type;
} else if (editingField.value) {
defaultValues.type = editingField.value;
}
}
setCurrentField(defaultValues);
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
reset({
type: defaultValues.type,
label: defaultValues.label,
placeholder: defaultValues.placeholder,
required: defaultValues.required,
icon: defaultValues.icon,
text: defaultValues.text,
acceptTypes: defaultValues.acceptTypes,
maxSize: defaultValues.maxSize,
checked: defaultValues.checked,
signatureData: defaultValues.signatureData,
backgroundColor: defaultValues.backgroundColor,
penColor: defaultValues.penColor,
penWidth: defaultValues.penWidth,
validation: defaultValues.validation,
});
}
}, [isOpen, editingField, reset, isEditing]);
// Ajouter une option au select
const addOption = (e) => {
// Arrêter la propagation de l'événement pour éviter que le clic n'atteigne l'arrière-plan
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (newOption.trim()) {
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
const currentOptions = Array.isArray(currentField.options)
? currentField.options
: [];
setCurrentField({
...currentField,
options: [...currentOptions, newOption.trim()],
});
setNewOption('');
}
};
// Supprimer une option du select
const removeOption = (index) => {
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
const currentOptions = Array.isArray(currentField.options)
? currentField.options
: [];
const newOptions = currentOptions.filter((_, i) => i !== index);
setCurrentField({ ...currentField, options: newOptions });
};
// Sélectionner une icône
const selectIcon = (iconName) => {
setCurrentField({ ...currentField, icon: iconName });
// Mettre à jour la valeur dans le formulaire
const iconField = control._fields.icon;
if (iconField && iconField.onChange) {
iconField.onChange(iconName);
}
};
const handleFieldSubmit = (data) => {
onSubmit(data, currentField, editingIndex);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold">
{isEditing ? 'Modifier le champ' : 'Ajouter un champ'}
</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl"
>
</button>
</div>
<form onSubmit={handleSubmit(handleFieldSubmit)} className="space-y-4">
<Controller
name="type"
control={control}
defaultValue={currentField.type}
render={({ field: { onChange, value } }) => (
<SelectChoice
label="Type de champ"
name="type"
selected={value}
callback={(e) => {
const newType = e.target.value;
onChange(newType);
// Assurons-nous que les options restent un tableau si on sélectionne select ou radio
let updatedOptions = currentField.options;
// Si options n'existe pas ou n'est pas un tableau, initialiser comme tableau vide
if (!updatedOptions || !Array.isArray(updatedOptions)) {
updatedOptions = [];
}
setCurrentField({
...currentField,
type: newType,
options: updatedOptions,
});
}}
choices={FIELD_TYPES_WITH_ICONS}
placeHolder="Sélectionner un type"
required
showIcons={true}
customSelect={true}
/>
)}
/>
{![
'paragraph',
'heading1',
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
].includes(currentField.type) && (
<>
<Controller
name="label"
control={control}
defaultValue={currentField.label}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Label du champ"
name="label"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
label: e.target.value,
});
}}
required
/>
)}
/>
<Controller
name="placeholder"
control={control}
defaultValue={currentField.placeholder}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Placeholder (optionnel)"
name="placeholder"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
placeholder: e.target.value,
});
}}
/>
)}
/>
<div className="flex items-center">
<Controller
name="required"
control={control}
defaultValue={currentField.required}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="required"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
required: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="required">Champ obligatoire</label>
</div>
{(currentField.type === 'text' ||
currentField.type === 'email' ||
currentField.type === 'date') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Icône (optionnel)
</label>
<Controller
name="icon"
control={control}
defaultValue={currentField.icon}
render={({ field: { onChange } }) => (
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 p-3 border border-gray-300 rounded-md bg-gray-50">
{currentField.icon &&
LucideIcons[currentField.icon] ? (
<>
{React.createElement(
LucideIcons[currentField.icon],
{
size: 20,
className: 'text-gray-600',
}
)}
<span className="text-sm text-gray-700">
{currentField.icon}
</span>
</>
) : (
<span className="text-sm text-gray-500">
Aucune icône sélectionnée
</span>
)}
</div>
<Button
type="button"
text="Choisir"
onClick={(e) => {
e.preventDefault();
setShowIconPicker(true);
}}
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
/>
{currentField.icon && (
<Button
type="button"
text="✕"
onClick={() => {
onChange('');
setCurrentField({ ...currentField, icon: '' });
}}
className="px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
/>
)}
</div>
)}
/>
</div>
)}
</>
)}
{[
'paragraph',
'heading1',
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
].includes(currentField.type) && (
<Controller
name="text"
control={control}
defaultValue={currentField.text}
render={({ field: { onChange, value } }) => (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{currentField.type === 'paragraph'
? 'Texte du paragraphe'
: 'Texte du titre'}
</label>
<textarea
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
text: e.target.value,
});
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
required
/>
</div>
)}
/>
)}
{currentField.type === 'select' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Options de la liste
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newOption}
onChange={(e) => setNewOption(e.target.value)}
placeholder="Nouvelle option"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyPress={(e) =>
e.key === 'Enter' && (e.preventDefault(), addOption())
}
/>
<Button
type="button"
text="Ajouter"
onClick={addOption}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
/>
</div>
<div className="space-y-1">
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
))}
</div>
</div>
)}
{currentField.type === 'radio' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Options des boutons radio
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={newOption}
onChange={(e) => setNewOption(e.target.value)}
placeholder="Nouvelle option"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyPress={(e) =>
e.key === 'Enter' && (e.preventDefault(), addOption())
}
/>
<Button
type="button"
text="Ajouter"
onClick={addOption}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
/>
</div>
<div className="space-y-1">
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
))}
</div>
</div>
)}
{currentField.type === 'phone' && (
<Controller
name="validation.pattern"
control={control}
defaultValue={currentField.validation?.pattern || ''}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Format de téléphone (optionnel, exemple: ^\\+?[0-9]{10,15}$)"
name="phonePattern"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
validation: {
...currentField.validation,
pattern: e.target.value,
},
});
}}
/>
)}
/>
)}
{currentField.type === 'file' && (
<>
<Controller
name="acceptTypes"
control={control}
defaultValue={currentField.acceptTypes || ''}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Types de fichiers acceptés (ex: .pdf,.jpg,.png)"
name="acceptTypes"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
acceptTypes: e.target.value,
});
}}
/>
)}
/>
<Controller
name="maxSize"
control={control}
defaultValue={currentField.maxSize || 5}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Taille maximale (MB)"
name="maxSize"
type="number"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
maxSize: parseInt(e.target.value) || 5,
});
}}
/>
)}
/>
</>
)}
{currentField.type === 'signature' && (
<>
<Controller
name="backgroundColor"
control={control}
defaultValue={currentField.backgroundColor || '#ffffff'}
render={({ field: { onChange, value } }) => (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Couleur de fond
</label>
<input
type="color"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
backgroundColor: e.target.value,
});
}}
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
/>
</div>
)}
/>
<Controller
name="penColor"
control={control}
defaultValue={currentField.penColor || '#000000'}
render={({ field: { onChange, value } }) => (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Couleur du stylo
</label>
<input
type="color"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
penColor: e.target.value,
});
}}
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
/>
</div>
)}
/>
<Controller
name="penWidth"
control={control}
defaultValue={currentField.penWidth || 2}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Épaisseur du stylo (px)"
name="penWidth"
type="number"
min="1"
max="10"
value={value}
onChange={(e) => {
onChange(parseInt(e.target.value));
setCurrentField({
...currentField,
penWidth: parseInt(e.target.value) || 2,
});
}}
/>
)}
/>
</>
)}
{currentField.type === 'checkbox' && (
<>
<div className="flex items-center mt-2">
<Controller
name="checked"
control={control}
defaultValue={currentField.checked || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="defaultChecked"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
checked: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="defaultChecked">Coché par défaut</label>
</div>
<div className="flex items-center mt-2">
<Controller
name="horizontal"
control={control}
defaultValue={currentField.horizontal || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="horizontal"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
horizontal: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="horizontal">Label au-dessus (horizontal)</label>
</div>
</>
)}
{currentField.type === 'toggle' && (
<div className="flex items-center mt-2">
<Controller
name="checked"
control={control}
defaultValue={currentField.checked || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="defaultToggled"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
checked: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="defaultToggled">Activé par défaut</label>
</div>
)}
<div className="flex gap-2 mt-6">
<Button
type="submit"
text={isEditing ? 'Modifier' : 'Ajouter'}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
/>
<Button
type="button"
text="Annuler"
onClick={onClose}
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
/>
</div>
</form>
{/* Sélecteur d'icônes - déplacé en dehors du formulaire */}
<IconSelector
isOpen={showIconPicker}
onClose={() => setShowIconPicker(false)}
onSelect={selectIcon}
selectedIcon={currentField.icon}
/>
</div>
</div>
);
}

View File

@ -7,7 +7,7 @@ const CheckBox = ({
handleChange,
fieldName,
itemLabelFunc = () => null,
horizontal,
horizontal = false,
}) => {
// Vérifier si formData[fieldName] est un tableau ou une valeur booléenne
const isChecked = Array.isArray(formData[fieldName])
@ -22,7 +22,7 @@ const CheckBox = ({
{horizontal && (
<label
htmlFor={`${fieldName}-${item.id}`}
className="block text-sm text-center mb-1 font-medium text-gray-700"
className="block text-sm text-center mb-1 font-medium text-gray-700 cursor-pointer"
>
{itemLabelFunc(item)}
</label>
@ -40,7 +40,7 @@ const CheckBox = ({
{!horizontal && (
<label
htmlFor={`${fieldName}-${item.id}`}
className="block text-sm text-center mb-1 font-medium text-gray-700"
className="block text-sm font-medium text-gray-700 cursor-pointer"
>
{itemLabelFunc(item)}
</label>

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { FIELD_TYPES } from './FormTypes';
import * as LucideIcons from 'lucide-react';
import Button from './Button';
// Utiliser les mêmes icônes que dans FormTemplateBuilder
const FIELD_TYPES_ICON = {
text: { icon: LucideIcons.TextCursorInput },
email: { icon: LucideIcons.AtSign },
phone: { icon: LucideIcons.Phone },
date: { icon: LucideIcons.Calendar },
select: { icon: LucideIcons.ChevronDown },
radio: { icon: LucideIcons.Radio },
checkbox: { icon: LucideIcons.CheckSquare },
toggle: { icon: LucideIcons.ToggleLeft },
file: { icon: LucideIcons.FileUp },
signature: { icon: LucideIcons.PenTool },
textarea: { icon: LucideIcons.Type },
paragraph: { icon: LucideIcons.AlignLeft },
heading1: { icon: LucideIcons.Heading1 },
heading2: { icon: LucideIcons.Heading2 },
heading3: { icon: LucideIcons.Heading3 },
heading4: { icon: LucideIcons.Heading4 },
heading5: { icon: LucideIcons.Heading5 },
heading6: { icon: LucideIcons.Heading6 },
};
export default function FieldTypeSelector({ isOpen, onClose, onSelect }) {
const [searchTerm, setSearchTerm] = useState('');
if (!isOpen) return null;
// Filtrer les types de champs selon le terme de recherche
const filteredFieldTypes = FIELD_TYPES.filter((fieldType) =>
fieldType.label.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectFieldType = (fieldType) => {
onSelect(fieldType);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Choisir un type de champ ({filteredFieldTypes.length} /{' '}
{FIELD_TYPES.length} types)
</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl"
>
</button>
</div>
{/* Barre de recherche */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="Rechercher un type de champ..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<LucideIcons.Search
className="absolute left-3 top-3.5 text-gray-400"
size={18}
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
>
<LucideIcons.X size={18} />
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredFieldTypes.map((fieldType) => {
const IconComponent = FIELD_TYPES_ICON[fieldType.value]?.icon;
return (
<button
key={fieldType.value}
onClick={() => selectFieldType(fieldType)}
className="p-5 rounded-lg border-2 border-gray-200 bg-gray-50
hover:bg-blue-50 hover:border-blue-300 hover:shadow-md hover:scale-105
flex flex-col items-center justify-center gap-4 min-h-[140px] w-full
transition-all duration-200"
title={fieldType.label}
>
{IconComponent && (
<IconComponent
size={32}
className="text-gray-700 flex-shrink-0"
/>
)}
<span className="text-sm text-gray-600 text-center font-medium">
{fieldType.label}
</span>
</button>
);
})}
</div>
<div className="mt-6 flex justify-end">
<Button
text="Annuler"
onClick={onClose}
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import * as LucideIcons from 'lucide-react';
import { FIELD_TYPES } from './FormTypes';
// Associer les icônes à chaque type de champ
export const FIELD_TYPES_WITH_ICONS = FIELD_TYPES.map((fieldType) => {
let icon = null;
switch (fieldType.value) {
case 'text':
icon = LucideIcons.TextCursorInput;
break;
case 'email':
icon = LucideIcons.AtSign;
break;
case 'phone':
icon = LucideIcons.Phone;
break;
case 'date':
icon = LucideIcons.Calendar;
break;
case 'select':
icon = LucideIcons.ChevronDown;
break;
case 'radio':
icon = LucideIcons.Radio;
break;
case 'checkbox':
icon = LucideIcons.CheckSquare;
break;
case 'toggle':
icon = LucideIcons.ToggleLeft;
break;
case 'file':
icon = LucideIcons.FileUp;
break;
case 'signature':
icon = LucideIcons.PenTool;
break;
case 'textarea':
icon = LucideIcons.Type;
break;
case 'paragraph':
icon = LucideIcons.AlignLeft;
break;
case 'heading1':
icon = LucideIcons.Heading1;
break;
case 'heading2':
icon = LucideIcons.Heading2;
break;
case 'heading3':
icon = LucideIcons.Heading3;
break;
case 'heading4':
icon = LucideIcons.Heading4;
break;
case 'heading5':
icon = LucideIcons.Heading5;
break;
case 'heading6':
icon = LucideIcons.Heading6;
break;
default:
break;
}
return {
...fieldType,
icon,
};
});
export default FIELD_TYPES_WITH_ICONS;

View File

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

View File

@ -1,11 +1,18 @@
import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react';
import Button from './Button';
import DjangoCSRFToken from '../DjangoCSRFToken';
import WisiwigTextArea from './WisiwigTextArea';
import RadioList from './RadioList';
import CheckBox from './CheckBox';
import ToggleSwitch from './ToggleSwitch';
import InputPhone from './InputPhone';
import FileUpload from './FileUpload';
import SignatureField from './SignatureField';
/*
* Récupère une icône Lucide par son nom.
@ -19,68 +26,149 @@ export function getIcon(name) {
}
}
const formConfigTest = {
id: 0,
title: 'Mon formulaire dynamique',
submitLabel: 'Envoyer',
fields: [
{ id: 'name', label: 'Nom', type: 'text', required: true },
{ id: 'email', label: 'Email', type: 'email' },
{
id: 'email2',
label: 'Email',
type: 'text',
icon: 'Mail',
},
{
id: 'role',
label: 'Rôle',
type: 'select',
options: ['Admin', 'Utilisateur', 'Invité'],
required: true,
},
{
type: 'paragraph',
text: "Bonjour, Bienvenue dans ce formulaire d'inscription haha",
},
{
id: 'birthdate',
label: 'Date de naissance',
type: 'date',
icon: 'Calendar',
},
{
id: 'textarea',
label: 'toto',
type: 'textarea',
},
],
};
export default function FormRenderer({
formConfig = formConfigTest,
formConfig,
csrfToken,
initialValues = {},
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel)
}) {
const {
handleSubmit,
control,
formState: { errors },
reset,
} = useForm();
} = useForm({ defaultValues: initialValues });
const onSubmit = (data) => {
// Réinitialiser le formulaire quand les valeurs initiales changent
useEffect(() => {
if (initialValues && Object.keys(initialValues).length > 0) {
reset(initialValues);
}
}, [initialValues, reset]);
// Fonction utilitaire pour envoyer les données au backend
const sendFormDataToBackend = async (formData) => {
try {
// Cette fonction peut être remplacée par votre propre implémentation
// Exemple avec fetch:
const response = await fetch('/api/submit-form', {
method: 'POST',
body: formData,
// Les en-têtes sont automatiquement définis pour FormData
});
if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status}`);
}
const result = await response.json();
logger.debug('Envoi réussi:', result);
return result;
} catch (error) {
logger.error("Erreur lors de l'envoi:", error);
throw error;
}
};
const onSubmit = async (data) => {
logger.debug('=== DÉBUT onSubmit ===');
logger.debug('Réponses :', data);
const formattedData = {
//TODO: idDossierInscriptions: 123,
formId: formConfig.id,
responses: { ...data },
};
try {
// Vérifier si nous avons des fichiers dans les données
const hasFiles = Object.keys(data).some((key) => {
return (
data[key] instanceof FileList ||
(data[key] && data[key][0] instanceof File) ||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
);
});
if (hasFiles) {
// Utiliser FormData pour l'envoi de fichiers
const formData = new FormData();
// Ajouter l'ID du formulaire
formData.append('formId', (formConfig?.id || 'unknown').toString());
// Traiter chaque champ et ses valeurs
Object.keys(data).forEach((key) => {
const value = data[key];
if (
value instanceof FileList ||
(value && value[0] instanceof File)
) {
// Gérer les champs de type fichier
if (value.length > 0) {
for (let i = 0; i < value.length; i++) {
formData.append(`files.${key}`, value[i]);
}
}
} else if (
typeof value === 'string' &&
value.startsWith('data:image')
) {
// Gérer les signatures (SVG ou images base64)
if (value.includes('svg+xml')) {
// Gérer les signatures SVG
const svgData = value.split(',')[1];
const svgBlob = new Blob([atob(svgData)], {
type: 'image/svg+xml',
});
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
} else {
// Gérer les images base64 classiques
const byteString = atob(value.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: 'image/png' });
formData.append(`files.${key}`, blob, `signature_${key}.png`);
}
} else {
// Gérer les autres types de champs
formData.append(
`data.${key}`,
value !== undefined ? value.toString() : ''
);
}
});
if (onFormSubmit) {
// Utiliser le callback personnalisé si fourni
await onFormSubmit(formData, true);
} else {
// Sinon, utiliser la fonction par défaut
await sendFormDataToBackend(formData);
alert('Formulaire avec fichier(s) envoyé avec succès');
}
} else {
// Pas de fichier, on peut utiliser JSON
const formattedData = {
formId: formConfig?.id || 'unknown',
responses: { ...data },
};
if (onFormSubmit) {
// Utiliser le callback personnalisé si fourni
await onFormSubmit(formattedData, false);
} else {
// Afficher un message pour démonstration
alert('Données reçues : ' + JSON.stringify(formattedData, null, 2));
}
}
reset(); // Réinitialiser le formulaire après soumission
} catch (error) {
logger.error('Erreur lors de la soumission du formulaire:', error);
alert(`Erreur lors de l'envoi du formulaire: ${error.message}`);
}
//TODO: ENVOYER LES DONNÉES AU BACKEND
alert('Données reçues : ' + JSON.stringify(formattedData, null, 2));
reset(); // Réinitialiser le formulaire après soumission
logger.debug('=== FIN onSubmit ===');
};
@ -97,12 +185,34 @@ export default function FormRenderer({
>
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
<h2 className="text-2xl font-bold text-center mb-4">
{formConfig.title}
{formConfig?.title || 'Formulaire'}
</h2>
{formConfig.fields.map((field) => (
<div key={field.id} className="flex flex-col mt-4">
{field.type === 'paragraph' && <p>{field.text}</p>}
{(formConfig?.fields || []).map((field) => (
<div
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
className="flex flex-col mt-4"
>
{field.type === 'heading1' && (
<h1 className="text-3xl font-bold mb-3">{field.text}</h1>
)}
{field.type === 'heading2' && (
<h2 className="text-2xl font-bold mb-3">{field.text}</h2>
)}
{field.type === 'heading3' && (
<h3 className="text-xl font-bold mb-2">{field.text}</h3>
)}
{field.type === 'heading4' && (
<h4 className="text-lg font-bold mb-2">{field.text}</h4>
)}
{field.type === 'heading5' && (
<h5 className="text-base font-bold mb-1">{field.text}</h5>
)}
{field.type === 'heading6' && (
<h6 className="text-sm font-bold mb-1">{field.text}</h6>
)}
{field.type === 'paragraph' && <p className="mb-4">{field.text}</p>}
{(field.type === 'text' ||
field.type === 'email' ||
@ -110,7 +220,14 @@ export default function FormRenderer({
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
rules={{
required: field.required,
pattern: field.validation?.pattern
? new RegExp(field.validation.pattern)
: undefined,
minLength: field.validation?.minLength,
maxLength: field.validation?.maxLength,
}}
render={({ field: { onChange, value, name } }) => (
<InputTextIcon
label={field.label}
@ -131,6 +248,29 @@ export default function FormRenderer({
)}
/>
)}
{field.type === 'phone' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<InputPhone
label={field.label}
required={field.required}
name={name}
value={value || ''}
onChange={onChange}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'select' && (
<Controller
name={field.id}
@ -156,6 +296,111 @@ export default function FormRenderer({
)}
/>
)}
{field.type === 'radio' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<RadioList
items={field.options.map((option, idx) => ({
id: idx,
label: option,
}))}
formData={{
[field.id]: value
? field.options.findIndex((o) => o === value)
: '',
}}
handleChange={(e) =>
onChange(field.options[parseInt(e.target.value)])
}
fieldName={field.id}
sectionLabel={field.label}
required={field.required}
/>
)}
/>
)}
{field.type === 'checkbox' && (
<Controller
name={field.id}
control={control}
defaultValue={field.checked || false}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<div>
<CheckBox
item={{ id: field.id, label: field.label }}
formData={{ [field.id]: value || false }}
handleChange={(e) => onChange(e.target.checked)}
fieldName={field.id}
itemLabelFunc={(item) => item.label}
horizontal={field.horizontal || false}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
{field.type === 'toggle' && (
<Controller
name={field.id}
control={control}
defaultValue={field.checked || false}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<div>
<ToggleSwitch
name={field.id}
label={field.label + (field.required ? ' *' : '')}
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
{field.type === 'file' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<FileUpload
selectionMessage={field.label}
required={field.required}
uploadedFileName={value ? value[0]?.name : null}
onFileSelect={(file) => {
// Créer un objet de type FileList similaire pour la compatibilité
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
onChange(dataTransfer.files);
}}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'textarea' && (
<Controller
name={field.id}
@ -179,13 +424,40 @@ export default function FormRenderer({
)}
/>
)}
{field.type === 'signature' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<div>
<SignatureField
label={field.label}
required={field.required}
value={value || ''}
onChange={onChange}
backgroundColor={field.backgroundColor || '#ffffff'}
penColor={field.penColor || '#000000'}
penWidth={field.penWidth || 2}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
</div>
))}
<div className="form-group-submit mt-4">
<Button
type="submit"
primary
text={formConfig.submitLabel ? formConfig.submitLabel : 'Envoyer'}
text={formConfig?.submitLabel || 'Envoyer'}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
/>
</div>

View File

@ -0,0 +1,703 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import InputTextIcon from './InputTextIcon';
import FormRenderer from './FormRenderer';
import AddFieldModal from './AddFieldModal';
import FieldTypeSelector from './FieldTypeSelector';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import {
Edit2,
Trash2,
PlusCircle,
Download,
Upload,
GripVertical,
TextCursorInput,
AtSign,
Calendar,
ChevronDown,
Type,
AlignLeft,
Save,
ChevronUp,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Code,
Eye,
EyeOff,
Phone,
Radio,
ToggleLeft,
CheckSquare,
FileUp,
PenTool,
} from 'lucide-react';
import CheckBox from '@/components/Form/CheckBox';
const FIELD_TYPES_ICON = {
text: { icon: TextCursorInput },
email: { icon: AtSign },
phone: { icon: Phone },
date: { icon: Calendar },
select: { icon: ChevronDown },
radio: { icon: Radio },
checkbox: { icon: CheckSquare },
toggle: { icon: ToggleLeft },
file: { icon: FileUp },
signature: { icon: PenTool },
textarea: { icon: Type },
paragraph: { icon: AlignLeft },
heading1: { icon: Heading1 },
heading2: { icon: Heading2 },
heading3: { icon: Heading3 },
heading4: { icon: Heading4 },
heading5: { icon: Heading5 },
heading6: { icon: Heading6 },
};
// Type d'item pour le drag and drop
const ItemTypes = {
FIELD: 'field',
};
// Composant pour un champ draggable
const DraggableFieldItem = ({
field,
index,
moveField,
editField,
deleteField,
}) => {
const ref = React.useRef(null);
// Configuration du drag (ce qu'on peut déplacer)
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.FIELD,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
// Configuration du drop (où on peut déposer)
const [, drop] = useDrop({
accept: ItemTypes.FIELD,
hover: (item, monitor) => {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
// Ne rien faire si on survole le même élément
if (dragIndex === hoverIndex) {
return;
}
// Déterminer la position de la souris par rapport à l'élément survolé
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Ne pas remplacer si on n'a pas dépassé la moitié de l'élément
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Effectuer le déplacement
moveField(dragIndex, hoverIndex);
// Mettre à jour l'index de l'élément déplacé
item.index = hoverIndex;
},
});
// Combiner drag et drop sur le même élément de référence
drag(drop(ref));
return (
<div
ref={ref}
className={`flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-200 ${
isDragging ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-2">
<div className="cursor-move text-gray-400 hover:text-gray-600">
<GripVertical size={18} />
</div>
{FIELD_TYPES_ICON[field.type] &&
React.createElement(FIELD_TYPES_ICON[field.type].icon, {
size: 18,
className: 'text-gray-600',
})}
<span className="font-medium">
{field.type === 'paragraph'
? 'Paragraphe'
: field.type.startsWith('heading')
? `Titre ${field.type.replace('heading', '')}`
: field.label}
</span>
<span className="text-sm text-gray-500">
({field.type}){field.required && ' *'}
</span>
</div>
<div className="flex gap-1">
<button
onClick={() => editField(index)}
className="p-1 text-blue-500 hover:text-blue-700"
title="Modifier"
>
<Edit2 size={16} />
</button>
<button
onClick={() => deleteField(index)}
className="p-1 text-red-500 hover:text-red-700"
title="Supprimer"
>
<Trash2 size={16} />
</button>
</div>
</div>
);
};
export default function FormTemplateBuilder({
onSave,
initialData,
groups,
isEditing,
}) {
const [formConfig, setFormConfig] = useState({
id: initialData?.id || 0,
title: initialData?.name || 'Nouveau formulaire',
submitLabel: 'Envoyer',
fields: initialData?.formMasterData?.fields || [],
});
const [selectedGroups, setSelectedGroups] = useState(
initialData?.groups?.map((g) => g.id) || []
);
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const [selectedFieldType, setSelectedFieldType] = useState(null);
const [editingIndex, setEditingIndex] = useState(-1);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
const [showScrollButton, setShowScrollButton] = useState(false);
const [showJsonSection, setShowJsonSection] = useState(false);
const { reset: resetField } = useForm();
// Initialiser les données du formulaire quand initialData change
useEffect(() => {
if (initialData) {
setFormConfig({
id: initialData.id || 0,
title: initialData.name || 'Nouveau formulaire',
submitLabel: 'Envoyer',
fields: initialData.formMasterData?.fields || [],
});
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
}
}, [initialData]);
// Gérer l'affichage du bouton de défilement
useEffect(() => {
const handleScroll = () => {
// Afficher le bouton quand on descend d'au moins 300px
setShowScrollButton(window.scrollY > 300);
};
// Ajouter l'écouteur d'événement
window.addEventListener('scroll', handleScroll);
// Nettoyage de l'écouteur lors du démontage du composant
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
// Fonction pour remonter en haut de la page
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
// Générer un ID unique pour les champs
const generateFieldId = (label) => {
return label
.toLowerCase()
.replace(/[àáâãäå]/g, 'a')
.replace(/[èéêë]/g, 'e')
.replace(/[ìíîï]/g, 'i')
.replace(/[òóôõö]/g, 'o')
.replace(/[ùúûü]/g, 'u')
.replace(/[ç]/g, 'c')
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
};
// Ajouter ou modifier un champ
const handleFieldSubmit = (data, currentField, editIndex) => {
const isHeadingType = data.type.startsWith('heading');
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
const fieldData = {
...data,
id: isContentTypeOnly
? undefined
: generateFieldId(data.label || 'field'),
options: ['select', 'radio'].includes(data.type)
? Array.isArray(currentField.options)
? currentField.options
: []
: undefined,
icon: data.icon || currentField.icon || undefined,
placeholder: data.placeholder || undefined,
text: isContentTypeOnly ? data.text : undefined,
checked: ['checkbox', 'toggle'].includes(data.type)
? currentField.checked
: undefined,
horizontal:
data.type === 'checkbox' ? currentField.horizontal : undefined,
acceptTypes: data.type === 'file' ? currentField.acceptTypes : undefined,
maxSize: data.type === 'file' ? currentField.maxSize : undefined,
validation: ['phone', 'email', 'text'].includes(data.type)
? currentField.validation
: undefined,
};
// Nettoyer les propriétés undefined
Object.keys(fieldData).forEach((key) => {
if (fieldData[key] === undefined || fieldData[key] === '') {
delete fieldData[key];
}
});
const newFields = [...formConfig.fields];
if (editIndex >= 0) {
newFields[editIndex] = fieldData;
} else {
newFields.push(fieldData);
}
setFormConfig({ ...formConfig, fields: newFields });
setEditingIndex(-1);
}; // Modifier un champ existant
const editField = (index) => {
setEditingIndex(index);
setShowAddFieldModal(true);
};
// Supprimer un champ
const deleteField = (index) => {
const newFields = formConfig.fields.filter((_, i) => i !== index);
setFormConfig({ ...formConfig, fields: newFields });
};
// Déplacer un champ
const moveField = (dragIndex, hoverIndex) => {
const newFields = [...formConfig.fields];
const draggedField = newFields[dragIndex];
// Supprimer l'élément déplacé
newFields.splice(dragIndex, 1);
// Insérer l'élément à sa nouvelle position
newFields.splice(hoverIndex, 0, draggedField);
setFormConfig({ ...formConfig, fields: newFields });
};
// Exporter le JSON
const exportJson = () => {
const jsonString = JSON.stringify(formConfig, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `formulaire_${formConfig.title.replace(/\s+/g, '_').toLowerCase()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Importer un JSON
const importJson = (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result);
setFormConfig(imported);
} catch (error) {
alert('Erreur lors de l&apos;importation du fichier JSON');
}
};
reader.readAsText(file);
}
};
// Sauvegarder le formulaire (pour le backend)
const saveFormTemplate = async () => {
// Validation basique
if (!formConfig.title.trim()) {
setSaveMessage({
type: 'error',
text: 'Le titre du formulaire est requis',
});
return;
}
if (formConfig.fields.length === 0) {
setSaveMessage({
type: 'error',
text: 'Ajoutez au moins un champ au formulaire',
});
return;
}
if (selectedGroups.length === 0) {
setSaveMessage({
type: 'error',
text: "Sélectionnez au moins un groupe d'inscription",
});
return;
}
setSaving(true);
setSaveMessage({ type: '', text: '' });
try {
const dataToSave = {
name: formConfig.title,
group_ids: selectedGroups,
formMasterData: formConfig,
};
if (isEditing && initialData) {
dataToSave.id = initialData.id;
}
if (onSave) {
onSave(dataToSave);
}
setSaveMessage({
type: 'success',
text: 'Formulaire enregistré avec succès',
});
} catch (error) {
setSaveMessage({
type: 'error',
text:
error.message || "Une erreur est survenue lors de l'enregistrement",
});
} finally {
setSaving(false);
}
};
// Fonction pour gérer la sélection d'un type de champ
const handleFieldTypeSelect = (fieldType) => {
setSelectedFieldType(fieldType);
setShowFieldTypeSelector(false);
setShowAddFieldModal(true);
};
return (
<DndProvider backend={HTML5Backend}>
<div className="max-w-6xl mx-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Panel de configuration */}
<div
className={
showJsonSection
? 'lg:col-span-3 space-y-6'
: 'lg:col-span-5 space-y-6'
}
>
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-bold mb-6">
Configuration du formulaire
</h2>
{/* Configuration générale */}
<div className="space-y-4 mb-6">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold mr-4">
Paramètres généraux
</h3>
<div className="flex gap-2">
<button
onClick={() => setShowJsonSection(!showJsonSection)}
className="px-4 py-2 rounded-md inline-flex items-center gap-2 bg-gray-500 hover:bg-gray-600 text-white"
title={
showJsonSection ? 'Masquer le JSON' : 'Afficher le JSON'
}
>
{showJsonSection ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
<span>
{showJsonSection ? 'Masquer JSON' : 'Afficher JSON'}
</span>
</button>
<button
onClick={saveFormTemplate}
disabled={saving}
className={`px-4 py-2 rounded-md inline-flex items-center gap-2 ${
saving
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
title="Enregistrer le formulaire"
>
<Save size={18} />
<span>
{saving ? 'Enregistrement...' : 'Enregistrer'}
</span>
</button>
</div>
</div>
{saveMessage.text && (
<div
className={`p-3 rounded ${
saveMessage.type === 'error'
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
}`}
>
{saveMessage.text}
</div>
)}
<InputTextIcon
label="Titre du formulaire"
name="title"
value={formConfig.title}
onChange={(e) =>
setFormConfig({ ...formConfig, title: e.target.value })
}
required
/>
<InputTextIcon
label="Texte du bouton de soumission du formulaire"
name="submitLabel"
value={formConfig.submitLabel}
onChange={(e) =>
setFormConfig({
...formConfig,
submitLabel: e.target.value,
})
}
/>
{/* Sélecteur de groupes */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroups
}}
handleChange={() => {
let group_ids = selectedGroups;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
} else {
group_ids = [...group_ids, group.id];
}
setSelectedGroups(group_ids);
}}
fieldName="groups"
itemLabelFunc={() => group.name}
/>
))
) : (
<p className="text-gray-500 text-sm">
Aucun groupe disponible
</p>
)}
</div>
</div>
</div>
{/* Liste des champs */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold mr-4">
Champs du formulaire ({formConfig.fields.length})
</h3>
<button
onClick={() => {
setEditingIndex(-1);
setSelectedFieldType(null);
setShowFieldTypeSelector(true);
}}
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
title="Ajouter un champ"
>
<PlusCircle size={18} />
</button>
</div>
{formConfig.fields.length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
<p className="text-gray-500 italic mb-4">
Aucun champ ajouté
</p>
<button
onClick={() => {
setEditingIndex(-1);
setSelectedFieldType(null);
setShowFieldTypeSelector(true);
}}
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
>
<PlusCircle size={18} />
<span>Ajouter mon premier champ</span>
</button>
</div>
) : (
<div className="space-y-2">
{formConfig.fields.map((field, index) => (
<DraggableFieldItem
key={index}
field={field}
index={index}
moveField={moveField}
editField={editField}
deleteField={deleteField}
/>
))}
</div>
)}
</div>
{/* Actions */}
<div className="mt-6">
{/* Les actions ont été déplacées dans la section JSON généré */}
</div>
</div>
</div>
{/* JSON généré */}
{showJsonSection && (
<div className="lg:col-span-2">
<div className="bg-white p-6 rounded-lg shadow h-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold mr-4">JSON généré</h3>
<div className="flex gap-2">
<button
onClick={exportJson}
className="p-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
title="Exporter JSON"
>
<Download size={18} />
</button>
<label
className="p-2 bg-orange-500 text-white rounded-md hover:bg-orange-600 cursor-pointer transition-colors"
title="Importer JSON"
>
<Upload size={18} />
<input
type="file"
accept=".json"
onChange={importJson}
className="hidden"
/>
</label>
</div>
</div>
<pre className="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
{JSON.stringify(formConfig, null, 2)}
</pre>
</div>
</div>
)}
</div>
{/* Aperçu */}
<div className="mt-6">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
{formConfig.fields.length > 0 ? (
<FormRenderer formConfig={formConfig} />
) : (
<p className="text-gray-500 italic text-center">
Ajoutez des champs pour voir l&apos;aperçu
</p>
)}
</div>
</div>
</div>
{/* Modal d'ajout/modification de champ */}
<AddFieldModal
isOpen={showAddFieldModal}
onClose={() => setShowAddFieldModal(false)}
onSubmit={handleFieldSubmit}
editingField={
editingIndex >= 0
? formConfig.fields[editingIndex]
: selectedFieldType
? { type: selectedFieldType.value || selectedFieldType }
: null
}
editingIndex={editingIndex}
/>
{/* Sélecteur de type de champ */}
<FieldTypeSelector
isOpen={showFieldTypeSelector}
onClose={() => setShowFieldTypeSelector(false)}
onSelect={handleFieldTypeSelect}
/>
{/* Bouton flottant pour remonter en haut */}
{showScrollButton && (
<div className="fixed bottom-6 right-6 z-10">
<button
onClick={scrollToTop}
className="p-4 rounded-full shadow-lg flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white transition-all duration-300"
title="Remonter en haut de la page"
>
<ChevronUp size={24} />
</button>
</div>
)}
</div>
</DndProvider>
);
}

View File

@ -0,0 +1,20 @@
export const FIELD_TYPES = [
{ value: 'text', label: 'Texte' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Téléphone' },
{ value: 'date', label: 'Date' },
{ value: 'select', label: 'Liste déroulante' },
{ value: 'radio', label: 'Boutons radio' },
{ value: 'checkbox', label: 'Case à cocher' },
{ value: 'toggle', label: 'Interrupteur' },
{ value: 'file', label: 'Upload de fichier' },
{ value: 'signature', label: 'Signature' },
{ value: 'textarea', label: 'Zone de texte riche' },
{ value: 'paragraph', label: 'Paragraphe' },
{ value: 'heading1', label: 'Titre 1' },
{ value: 'heading2', label: 'Titre 2' },
{ value: 'heading3', label: 'Titre 3' },
{ value: 'heading4', label: 'Titre 4' },
{ value: 'heading5', label: 'Titre 5' },
{ value: 'heading6', label: 'Titre 6' },
];

View File

@ -0,0 +1,145 @@
import React, { useMemo, useState } from 'react';
import * as LucideIcons from 'lucide-react';
import Button from './Button';
export default function IconSelector({
isOpen,
onClose,
onSelect,
selectedIcon = '',
}) {
const [searchTerm, setSearchTerm] = useState('');
const excludedKeys = new Set([
'Icon',
'DynamicIcon',
'createLucideIcon',
'default',
'icons',
]);
const allIcons = Object.keys(LucideIcons).filter((key) => {
// Exclure les utilitaires
if (excludedKeys.has(key)) return false;
return true;
});
const filteredIcons = useMemo(() => {
if (!searchTerm) return allIcons;
return allIcons.filter((iconName) =>
iconName.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [searchTerm, allIcons]);
if (!isOpen) return null;
const selectIcon = (iconName) => {
onSelect(iconName);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Choisir une icône ({filteredIcons.length} / {allIcons.length}{' '}
icônes)
</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl"
>
</button>
</div>
{/* Barre de recherche */}
<div className="mb-6">
<div className="relative">
<input
type="text"
placeholder="Rechercher une icône..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<LucideIcons.Search
className="absolute left-3 top-3.5 text-gray-400"
size={18}
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
>
<LucideIcons.X size={18} />
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{filteredIcons.map((iconName) => {
try {
const IconComponent = LucideIcons[iconName];
return (
<button
key={iconName}
onClick={() => selectIcon(iconName)}
className={`
p-5 rounded-lg border-2 transition-all duration-200
hover:bg-blue-50 hover:border-blue-300 hover:shadow-md hover:scale-105
flex flex-col items-center justify-center gap-4 min-h-[140px] w-full
${
selectedIcon === iconName
? 'bg-blue-100 border-blue-500 shadow-md scale-105'
: 'bg-gray-50 border-gray-200'
}
`}
title={iconName}
>
<IconComponent
size={32}
className="text-gray-700 flex-shrink-0"
/>
<span className="text-xs text-gray-600 text-center leading-tight break-words px-1 overflow-hidden max-w-full">
{iconName}
</span>
</button>
);
} catch (error) {
// En cas d'erreur avec une icône spécifique, ne pas la rendre
return null;
}
})}
</div>
<div className="mt-6 flex justify-between items-center">
<p className="text-sm text-gray-500">
{searchTerm ? (
<>
{filteredIcons.length} icône(s) trouvée(s) sur {allIcons.length}{' '}
disponibles
</>
) : (
<>Total : {allIcons.length} icônes disponibles</>
)}
</p>
<div className="flex gap-2">
<Button
text="Aucune icône"
onClick={() => selectIcon('')}
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
/>
<Button
text="Annuler"
onClick={onClose}
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,3 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
export default function SelectChoice({
type,
name,
@ -11,8 +13,46 @@ export default function SelectChoice({
errorLocalMsg,
IconItem,
disabled = false,
// Nouveaux paramètres
showIcons = false, // Activer l'affichage des icônes dans les options
customSelect = false, // Utiliser une liste personnalisée au lieu du select natif
}) {
const isPlaceholderSelected = selected === ''; // Vérifie si le placeholder est sélectionné
const [isOpen, setIsOpen] = useState(false);
const [selectedLabel, setSelectedLabel] = useState('');
const dropdownRef = useRef(null);
// Ferme le dropdown si on clique en dehors
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
}
if (customSelect) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [customSelect]);
// Met à jour le label affiché quand selected change
useEffect(() => {
if (selected && choices) {
const selectedOption = choices.find(
(option) => option.value === selected
);
if (selectedOption) {
setSelectedLabel(selectedOption.label);
}
} else {
setSelectedLabel('');
}
}, [selected, choices]);
// Affiche soit le select natif, soit notre version personnalisée avec icônes
return (
<>
<div>
@ -23,45 +63,172 @@ export default function SelectChoice({
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div
className={`mt-1 flex items-center border rounded-md ${
errorMsg || errorLocalMsg
? 'border-red-500 hover:border-red-700'
: 'border-gray-200 hover:border-gray-400'
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
>
{IconItem && (
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
{<IconItem />}
</span>
)}
<select
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
disabled ? 'bg-gray-100' : ''
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`} // Applique le style classique si une option autre que le placeholder est sélectionnée
type={type}
id={name}
name={name}
value={selected}
onChange={callback}
disabled={disabled}
{customSelect ? (
// Version personnalisée avec icônes dans un dropdown
<div ref={dropdownRef} className="relative">
<div
className={`mt-1 flex items-center border rounded-md cursor-pointer ${
errorMsg || errorLocalMsg
? 'border-red-500 hover:border-red-700'
: 'border-gray-200 hover:border-gray-400'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{IconItem && (
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
{<IconItem />}
</span>
)}
<div className="flex-1 px-3 py-2 block w-full sm:text-sm">
{isPlaceholderSelected ? (
<span className="italic text-gray-500">
{placeHolder?.toLowerCase()}
</span>
) : (
<div className="flex items-center">
{showIcons && selected && choices && (
<span className="mr-2">
{(() => {
const selectedOption = choices.find(
(option) => option.value === selected
);
if (selectedOption && selectedOption.icon) {
const IconComponent = selectedOption.icon;
return (
<IconComponent
size={18}
className="text-gray-600"
/>
);
}
return null;
})()}
</span>
)}
<span className="text-gray-800">{selectedLabel}</span>
</div>
)}
</div>
<div className="pr-2">
<svg
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
{isOpen && (
<div className="absolute z-10 mt-1 w-full bg-white shadow-lg rounded-md py-1 max-h-60 overflow-auto">
<div
className="px-3 py-2 text-gray-500 italic cursor-pointer hover:bg-gray-100"
onClick={() => {
if (!disabled) {
const event = { target: { value: '' } };
callback(event);
setIsOpen(false);
}
}}
>
{placeHolder?.toLowerCase()}
</div>
{choices.map(({ value, label, icon: Icon }) => (
<div
key={value}
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 ${
value === selected
? 'bg-blue-50 text-blue-700'
: 'text-gray-800'
}`}
onClick={() => {
if (!disabled) {
const event = { target: { value } };
callback(event);
setIsOpen(false);
}
}}
>
<div className="flex items-center">
{showIcons && Icon && (
<span className="mr-2">
<Icon
size={18}
className={
value === selected
? 'text-blue-600'
: 'text-gray-600'
}
/>
</span>
)}
<span>{label}</span>
</div>
</div>
))}
</div>
)}
{/* Input caché pour la compatibilité avec les formulaires */}
<input
type="hidden"
name={name}
id={name}
value={selected || ''}
onChange={() => {}} // Évite l'avertissement React pour input non contrôlé
/>
</div>
) : (
// Version standard avec select natif (pour la rétrocompatibilité)
<div
className={`mt-1 flex items-center border rounded-md ${
errorMsg || errorLocalMsg
? 'border-red-500 hover:border-red-700'
: 'border-gray-200 hover:border-gray-400'
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
>
{/* Placeholder en italique */}
<option value="" className="italic text-gray-500">
{placeHolder?.toLowerCase()}
</option>
{/* Autres options sans italique */}
{choices.map(({ value, label }) => (
<option
key={value}
value={value}
className="not-italic text-gray-800"
>
{label}
{IconItem && (
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
{<IconItem />}
</span>
)}
<select
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
disabled ? 'bg-gray-100' : ''
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`}
type={type}
id={name}
name={name}
value={selected}
onChange={callback}
disabled={disabled}
>
{/* Placeholder en italique */}
<option value="" className="italic text-gray-500">
{placeHolder?.toLowerCase()}
</option>
))}
</select>
</div>
{/* Autres options sans italique */}
{choices.map(({ value, label }) => (
<option
key={value}
value={value}
className="not-italic text-gray-800"
>
{label}
</option>
))}
</select>
</div>
)}
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
</div>
</>

View File

@ -0,0 +1,346 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { RotateCcw } from 'lucide-react';
const SignatureField = ({
label = 'Signature',
required = false,
onChange,
value,
disabled = false,
readOnly = false,
backgroundColor = '#ffffff',
penColor = '#000000',
penWidth = 2,
}) => {
const canvasRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
const [lastPosition, setLastPosition] = useState({ x: 0, y: 0 });
const [isEmpty, setIsEmpty] = useState(true);
const [svgPaths, setSvgPaths] = useState([]);
const [currentPath, setCurrentPath] = useState('');
const [smoothingPoints, setSmoothingPoints] = useState([]);
// Initialiser le canvas
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
// Support High DPI / Retina displays
const devicePixelRatio = window.devicePixelRatio || 1;
const displayWidth = 400;
const displayHeight = 200;
// Ajuster la taille physique du canvas pour la haute résolution
canvas.width = displayWidth * devicePixelRatio;
canvas.height = displayHeight * devicePixelRatio;
// Maintenir la taille d'affichage
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
// Adapter le contexte à la densité de pixels
context.scale(devicePixelRatio, devicePixelRatio);
// Améliorer l'anti-aliasing et le rendu
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.textRenderingOptimization = 'optimizeQuality';
// Configuration du style de dessin
context.fillStyle = backgroundColor;
context.fillRect(0, 0, displayWidth, displayHeight);
context.strokeStyle = penColor;
context.lineWidth = penWidth;
context.lineCap = 'round';
context.lineJoin = 'round';
context.globalCompositeOperation = 'source-over';
}, [backgroundColor, penColor, penWidth]);
useEffect(() => {
initializeCanvas();
// Si une valeur est fournie (signature existante), la charger
if (value && value !== '') {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (value.includes('svg+xml')) {
// Charger une signature SVG
const svgData = atob(value.split(',')[1]);
const img = new Image();
const svg = new Blob([svgData], {
type: 'image/svg+xml;charset=utf-8',
});
const url = URL.createObjectURL(svg);
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(img, 0, 0);
setIsEmpty(false);
URL.revokeObjectURL(url);
};
img.src = url;
} else {
// Charger une image classique
const img = new Image();
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(img, 0, 0);
setIsEmpty(false);
};
img.src = value;
}
}
}, [value, initializeCanvas, backgroundColor]);
// Obtenir les coordonnées relatives au canvas
const getCanvasPosition = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.style.width
? parseFloat(canvas.style.width) / rect.width
: 1;
const scaleY = canvas.style.height
? parseFloat(canvas.style.height) / rect.height
: 1;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
// Obtenir les coordonnées pour les événements tactiles
const getTouchPosition = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.style.width
? parseFloat(canvas.style.width) / rect.width
: 1;
const scaleY = canvas.style.height
? parseFloat(canvas.style.height) / rect.height
: 1;
return {
x: (e.touches[0].clientX - rect.left) * scaleX,
y: (e.touches[0].clientY - rect.top) * scaleY,
};
};
// Commencer le dessin
const startDrawing = useCallback(
(e) => {
if (disabled || readOnly) return;
e.preventDefault();
setIsDrawing(true);
const pos = e.type.includes('touch')
? getTouchPosition(e)
: getCanvasPosition(e);
setLastPosition(pos);
// Commencer un nouveau path SVG
setCurrentPath(`M ${pos.x},${pos.y}`);
setSmoothingPoints([pos]);
},
[disabled, readOnly]
);
// Dessiner
const draw = useCallback(
(e) => {
if (!isDrawing || disabled || readOnly) return;
e.preventDefault();
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
const currentPos = e.type.includes('touch')
? getTouchPosition(e)
: getCanvasPosition(e);
// Calculer la distance pour déterminer si on doit interpoler
const distance = Math.sqrt(
Math.pow(currentPos.x - lastPosition.x, 2) +
Math.pow(currentPos.y - lastPosition.y, 2)
);
// Si la distance est grande, interpoler pour un tracé plus lisse
if (distance > 2) {
const midPoint = {
x: (lastPosition.x + currentPos.x) / 2,
y: (lastPosition.y + currentPos.y) / 2,
};
// Utiliser une courbe quadratique pour un tracé plus lisse
context.beginPath();
context.moveTo(lastPosition.x, lastPosition.y);
context.quadraticCurveTo(
lastPosition.x,
lastPosition.y,
midPoint.x,
midPoint.y
);
context.stroke();
setLastPosition(midPoint);
setCurrentPath(
(prev) =>
prev +
` Q ${lastPosition.x},${lastPosition.y} ${midPoint.x},${midPoint.y}`
);
} else {
// Tracé direct pour les mouvements lents
context.beginPath();
context.moveTo(lastPosition.x, lastPosition.y);
context.lineTo(currentPos.x, currentPos.y);
context.stroke();
setLastPosition(currentPos);
setCurrentPath((prev) => prev + ` L ${currentPos.x},${currentPos.y}`);
}
setIsEmpty(false);
},
[isDrawing, lastPosition, disabled]
);
// Arrêter le dessin
const stopDrawing = useCallback(
(e) => {
if (!isDrawing) return;
e.preventDefault();
setIsDrawing(false);
// Ajouter le path terminé aux paths SVG
if (currentPath) {
setSvgPaths((prev) => [...prev, currentPath]);
setCurrentPath('');
}
// Notifier le parent du changement avec SVG
if (onChange) {
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
const svgData = generateSVG(newPaths);
onChange(svgData);
}
},
[isDrawing, onChange, svgPaths, currentPath]
);
// Générer le SVG à partir des paths
const generateSVG = (paths) => {
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${backgroundColor}"/>
${paths
.map(
(path) =>
`<path d="${path}" stroke="${penColor}" stroke-width="${penWidth}" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`
)
.join('\n ')}
</svg>`;
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
// Effacer la signature
const clearSignature = () => {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
// Effacer en tenant compte des dimensions d'affichage
const displayWidth = 400;
const displayHeight = 200;
context.clearRect(0, 0, displayWidth, displayHeight);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, displayWidth, displayHeight);
setIsEmpty(true);
setSvgPaths([]);
setCurrentPath('');
setSmoothingPoints([]);
if (onChange) {
onChange('');
}
};
return (
<div className="signature-field">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
<canvas
ref={canvasRef}
className={`border border-gray-200 bg-white rounded touch-none ${
readOnly ? 'cursor-default' : 'cursor-crosshair'
}`}
style={{
maxWidth: '100%',
height: 'auto',
opacity: disabled || readOnly ? 0.7 : 1,
cursor: disabled
? 'not-allowed'
: readOnly
? 'default'
: 'crosshair',
}}
onMouseDown={readOnly ? undefined : startDrawing}
onMouseMove={readOnly ? undefined : draw}
onMouseUp={readOnly ? undefined : stopDrawing}
onMouseLeave={readOnly ? undefined : stopDrawing}
onTouchStart={readOnly ? undefined : startDrawing}
onTouchMove={readOnly ? undefined : draw}
onTouchEnd={readOnly ? undefined : stopDrawing}
/>
<div className="flex justify-between items-center mt-3">
<div className="text-xs text-gray-500">
{readOnly
? isEmpty
? 'Aucune signature'
: 'Signature'
: isEmpty
? 'Signez dans la zone ci-dessus'
: 'Signature capturée'}
</div>
{!readOnly && (
<div className="flex gap-2">
<button
type="button"
onClick={clearSignature}
disabled={disabled || isEmpty}
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-100 text-red-600 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={12} />
Effacer
</button>
</div>
)}
</div>
{required && isEmpty && (
<div className="text-xs text-red-500 mt-1">
La signature est obligatoire
</div>
)}
</div>
</div>
);
};
export default SignatureField;

View File

@ -0,0 +1,466 @@
'use client';
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
* @param {Array} schoolFileTemplates - Liste des templates de formulaires
* @param {Object} existingResponses - Réponses déjà sauvegardées
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
* @param {Boolean} enable - Si les formulaires sont modifiables
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
*/
export default function DynamicFormsList({
schoolFileTemplates,
existingResponses = {},
onFormSubmit,
enable = true,
onValidationChange,
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
}) {
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [formsData, setFormsData] = useState({});
const [formsValidation, setFormsValidation] = useState({});
const fileInputRefs = React.useRef({});
// Initialiser les données avec les réponses existantes
useEffect(() => {
// Initialisation complète de formsValidation et formsData pour chaque template
if (schoolFileTemplates && schoolFileTemplates.length > 0) {
// Fusionner avec l'état existant pour préserver les données locales
setFormsData((prevData) => {
const dataState = { ...prevData };
schoolFileTemplates.forEach((tpl) => {
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
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;
});
// Fusionner avec l'état de validation existant
setFormsValidation((prevValidation) => {
const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => {
const hasLocalValidation = prevValidation[tpl.id] === true;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé
validationState[tpl.id] = true;
} else if (validationState[tpl.id] === undefined) {
// Pas encore initialisé : initialiser à false
validationState[tpl.id] = false;
}
// Si hasLocalValidation : on garde l'état local existant
});
return validationState;
});
}
}, [existingResponses, schoolFileTemplates]);
// Mettre à jour la validation globale quand la validation des formulaires change
useEffect(() => {
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
onValidationChange(allFormsValid);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
/**
* Gère la soumission d'un formulaire individuel
*/
const handleFormSubmit = async (formData, templateId) => {
try {
logger.debug('Soumission du formulaire:', { templateId, formData });
// Sauvegarder les données du formulaire
setFormsData((prev) => ({
...prev,
[templateId]: formData,
}));
// Marquer le formulaire comme complété
setFormsValidation((prev) => ({
...prev,
[templateId]: true,
}));
// Appeler le callback parent
if (onFormSubmit) {
await onFormSubmit(formData, templateId);
}
// Passer au formulaire suivant si disponible
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
setCurrentTemplateIndex(currentTemplateIndex + 1);
}
logger.debug('Formulaire soumis avec succès');
} catch (error) {
logger.error('Erreur lors de la soumission du formulaire:', error);
}
};
/**
* Vérifie si un formulaire est complété
*/
const isFormCompleted = (templateId) => {
return (
formsValidation[templateId] === true ||
(formsData[templateId] &&
Object.keys(formsData[templateId]).length > 0) ||
(existingResponses[templateId] &&
Object.keys(existingResponses[templateId]).length > 0)
);
};
/**
* Obtient l'icône de statut d'un formulaire
*/
const getFormStatusIcon = (templateId, isActive) => {
if (isFormCompleted(templateId)) {
return <CheckCircle className="w-5 h-5 text-green-600" />;
}
if (isActive) {
return <FileText className="w-5 h-5 text-blue-600" />;
}
return <Hourglass className="w-5 h-5 text-gray-400" />;
};
/**
* Obtient le formulaire actuel à afficher
*/
const getCurrentTemplate = () => {
return schoolFileTemplates[currentTemplateIndex];
};
const currentTemplate = getCurrentTemplate();
// Handler d'upload pour formulaire existant
const handleUpload = async (file, selectedFile) => {
if (!file || !selectedFile) return;
try {
const templateId = currentTemplate.id;
if (onFileUpload) {
await onFileUpload(file, selectedFile);
setFormsData((prev) => {
const newData = {
...prev,
[templateId]: { uploaded: true, fileName: file.name },
};
return newData;
});
setFormsValidation((prev) => {
const newValidation = {
...prev,
[templateId]: true,
};
return newValidation;
});
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
}
};
const isDynamicForm = (template) =>
template.formTemplateData &&
Array.isArray(template.formTemplateData.fields) &&
template.formTemplateData.fields.length > 0;
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
return (
<div className="text-center py-8">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600 mb-4">Aucun formulaire à compléter</p>
</div>
);
}
return (
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
{/* Liste des formulaires */}
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Formulaires à compléter
</h3>
<div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{
schoolFileTemplates.filter(tpl => {
// Validé ou complété localement
return tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
}).length
}
{' / '}
{schoolFileTemplates.length} complétés
</div>
{/* Tri des templates par état */}
{(() => {
// Helper pour état
const getState = tpl => {
if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
return getState(a) - getState(b);
});
return (
<ul className="space-y-2">
{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 className="w-3/4">
{currentTemplate && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-800">
{currentTemplate.name}
</h3>
{/* Label d'état */}
{currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
)}
</div>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
currentTemplate.description || ''}
</p>
<div className="text-xs text-gray-500 mt-1">
Formulaire {(() => {
// Trouver l'index du template courant dans la liste triée
const getState = tpl => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
return idx + 1;
})()} sur {schoolFileTemplates.length}
</div>
</div>
{/* Affichage dynamique ou existant */}
{isDynamicForm(currentTemplate) ? (
<FormRenderer
key={currentTemplate.id}
formConfig={{
id: currentTemplate.id,
title:
currentTemplate.formTemplateData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire',
fields:
currentTemplate.formTemplateData?.fields ||
currentTemplate.fields ||
[],
submitLabel:
currentTemplate.formTemplateData?.submitLabel || 'Valider',
}}
initialValues={
formsData[currentTemplate.id] ||
existingResponses[currentTemplate.id] ||
{}
}
onFormSubmit={(formData) =>
handleFormSubmit(formData, currentTemplate.id)
}
// Désactive le bouton suivant si le template est validé
enable={currentTemplate.isValidated !== true}
/>
) : (
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
{/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && (
<iframe
src={`${BASE_URL}${currentTemplate.file}`}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
/>
)}
{/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && (
<div className="flex flex-col items-center gap-4 w-full">
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
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>
)}
{/* Message de fin */}
{currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-green-600 mb-2">
Tous les formulaires ont été complétés !
</h3>
<p className="text-gray-600">
Vous pouvez maintenant passer à l&apos;étape suivante.
</p>
</div>
)}
</div>
</div>
);
}

View File

@ -5,11 +5,12 @@ import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
saveFormResponses,
fetchFormResponses,
autoSaveRegisterForm,
} from '@/app/actions/subscriptionAction';
import {
downloadTemplate,
editRegistrationSchoolFileTemplates,
editRegistrationParentFileTemplates,
} from '@/app/actions/registerFileGroupAction';
import {
fetchRegistrationPaymentModes,
@ -18,16 +19,16 @@ import {
fetchTuitionPaymentPlans,
} from '@/app/actions/schoolAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
import { FE_PARENTS_HOME_URL } from '@/utils/Url';
import logger from '@/utils/logger';
import FilesToUpload from '@/components/Inscription/FilesToUpload';
import { DocusealForm } from '@docuseal/react';
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
import AutoSaveIndicator from '@/components/AutoSaveIndicator';
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
import PaymentMethodSelector from '@/components/Inscription/PaymentMethodSelector';
import ProgressStep from '@/components/ProgressStep';
import { CheckCircle, Hourglass } from 'lucide-react';
import { useRouter } from 'next/navigation';
/**
@ -42,7 +43,6 @@ export default function InscriptionFormShared({
studentId,
csrfToken,
selectedEstablishmentId,
apiDocuseal,
onSubmit,
errors = {}, // Nouvelle prop pour les erreurs
enable = true,
@ -75,6 +75,8 @@ export default function InscriptionFormShared({
const [uploadedFiles, setUploadedFiles] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [formResponses, setFormResponses] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [isPage1Valid, setIsPage1Valid] = useState(false);
@ -90,6 +92,9 @@ export default function InscriptionFormShared({
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [profiles, setProfiles] = useState([]);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState(null);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const router = useRouter();
@ -121,18 +126,18 @@ export default function InscriptionFormShared({
}, [schoolFileTemplates]);
useEffect(() => {
// Vérifier si tous les templates ont leur champ "file" différent de null
const allSigned = schoolFileTemplates.every(
(template) => template.file !== null
);
// Vérifier si tous les formulaires maîtres sont complétés
const allCompleted =
schoolFileMasters.length === 0 ||
schoolFileMasters.every((master) => master.completed === true);
// Mettre à jour isPage4Valid en fonction de cette condition
setIsPage5Valid(allSigned);
// Mettre à jour isPage5Valid en fonction de cette condition
setIsPage5Valid(allCompleted);
if (allSigned) {
if (allCompleted) {
setCurrentTemplateIndex(0);
}
}, [schoolFileTemplates]);
}, [schoolFileMasters]);
useEffect(() => {
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
@ -145,62 +150,288 @@ export default function InscriptionFormShared({
logger.debug(allRequiredUploaded);
}, [parentFileTemplates]);
const handleTemplateSigned = (index) => {
const template = schoolFileTemplates[index];
// Auto-sauvegarde périodique (toutes les 30 secondes)
useEffect(() => {
if (!enable || !autoSaveEnabled) return;
if (!template) {
logger.error("Template introuvable pour l'index donné.");
const interval = setInterval(() => {
autoSave();
}, 30000); // 30 secondes
return () => clearInterval(interval);
}, [enable, autoSaveEnabled, formData, guardians, siblings]);
// Auto-sauvegarde quand les données changent (avec debounce)
useEffect(() => {
if (!enable || !autoSaveEnabled) return;
const timeout = setTimeout(() => {
autoSave();
}, 2000); // Attendre 2 secondes après le dernier changement
return () => clearTimeout(timeout);
}, [formData, guardians, siblings]);
/**
* Fonction d'auto-sauvegarde qui sauvegarde les données en cours
*/
const autoSave = async () => {
if (!autoSaveEnabled || !studentId || isSaving) {
return;
}
// Télécharger le template
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
.then((downloadUrl) => fetch(downloadUrl))
.then((response) => {
if (!response.ok) {
throw new Error('Erreur lors du téléchargement du fichier.');
}
return response.blob();
})
.then((blob) => {
const file = new File([blob], `${template.name}.pdf`, {
type: blob.type,
});
// Préparer les données pour la mise à jour
const updateData = new FormData();
updateData.append('file', file);
// Mettre à jour le template via l'API
return editRegistrationSchoolFileTemplates(
template.id,
updateData,
csrfToken
);
})
.then((updatedTemplate) => {
logger.debug('Template mis à jour avec succès :', updatedTemplate);
// Mettre à jour l'état local de schoolFileTemplates
setSchoolFileTemplates((prevTemplates) => {
const updatedTemplates = prevTemplates.map((t, i) =>
i === index ? { ...t, file: updatedTemplate.data.file } : t
);
logger.debug(
'État schoolFileTemplates mis à jour :',
updatedTemplates
);
return updatedTemplates;
});
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du template :', error);
try {
setIsSaving(true);
logger.debug('Auto-sauvegarde en cours...', {
studentId,
formDataKeys: Object.keys(formData),
paymentFields: {
registration_payment: formData.registration_payment,
registration_payment_plan: formData.registration_payment_plan,
tuition_payment: formData.tuition_payment,
tuition_payment_plan: formData.tuition_payment_plan,
},
guardians: guardians.length,
siblings: siblings.length,
currentPage,
});
// Fonction helper pour nettoyer les données avant sauvegarde
const cleanDataForAutoSave = (data) => {
const cleaned = {};
Object.keys(data).forEach((key) => {
const value = data[key];
// Garder seulement les valeurs non-vides et valides
if (value !== null && value !== undefined && value !== '') {
// Pour les dates, vérifier le format
if (key === 'birth_date' && value) {
// Vérifier que la date est dans un format valide
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (dateRegex.test(value)) {
cleaned[key] = value;
}
}
// Pour les codes postaux, vérifier que c'est un nombre
else if (key === 'birth_postal_code' && value) {
if (!isNaN(value) && value.toString().trim() !== '') {
cleaned[key] = parseInt(value);
}
}
// Pour les champs de paiement, toujours les inclure s'ils ont une valeur
else if (key.includes('payment') && value) {
cleaned[key] = value;
}
// Pour les autres champs, garder la valeur si elle n'est pas vide
else if (value.toString().trim() !== '') {
cleaned[key] = value;
}
}
});
return cleaned;
};
// Préparer les données à sauvegarder avec nettoyage
const cleanedFormData = cleanDataForAutoSave(formData);
const dataToSave = {
student: cleanedFormData,
guardians: guardians.filter(
(guardian) =>
guardian &&
(guardian.first_name || guardian.last_name || guardian.email)
),
siblings: siblings.filter(
(sibling) => sibling && (sibling.first_name || sibling.last_name)
),
currentPage: currentPage,
};
// Utiliser la fonction d'auto-save dédiée
await autoSaveRegisterForm(studentId, dataToSave, csrfToken);
setLastSaved(new Date());
logger.debug('Auto-sauvegarde réussie');
} catch (error) {
logger.error("Erreur lors de l'auto-sauvegarde:", error);
// Ne pas afficher d'erreur à l'utilisateur pour l'auto-save
} finally {
setIsSaving(false);
}
};
/**
* Gère la sauvegarde à chaque changement d'étape
*/
const saveStepData = async () => {
await autoSave();
};
/**
* Gère la soumission d'un formulaire dynamique
*/
const handleDynamicFormSubmit = async (formData, templateId) => {
try {
logger.debug('Soumission du formulaire dynamique:', {
templateId,
formData,
csrfToken: !!csrfToken,
});
// Trouver le template correspondant pour récupérer sa configuration
const currentTemplate = schoolFileTemplates.find(
(template) => template.id === templateId
);
if (!currentTemplate) {
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
}
// Construire la structure complète avec la configuration et les réponses
const formTemplateData = {
id: currentTemplate.id,
title:
currentTemplate.formTemplateData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire',
fields: (
currentTemplate.formTemplateData?.fields ||
currentTemplate.fields ||
[]
).map((field) => ({
...field,
...(field.type === 'checkbox'
? { checked: formData[field.id] || false }
: {}),
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
...(field.type === 'text' ||
field.type === 'textarea' ||
field.type === 'email'
? { value: formData[field.id] || '' }
: {}),
})),
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
responses: formData,
};
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
logger.debug('Appel API saveFormResponses avec:', {
templateId,
formTemplateData,
});
const result = await saveFormResponses(
templateId,
formTemplateData,
csrfToken
);
logger.debug("Réponse de l'API:", result);
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
let newResponses = formData;
if (
result &&
result.data &&
result.data.formTemplateData &&
result.data.formTemplateData.responses &&
result.data.formTemplateData.responses.responses
) {
// Si la structure responses.responses existe, on la prend
newResponses = result.data.formTemplateData.responses.responses;
} else if (
result &&
result.data &&
result.data.formTemplateData &&
result.data.formTemplateData.responses
) {
// Sinon, on prend responses directement
newResponses = result.data.formTemplateData.responses;
}
setFormResponses((prev) => ({
...prev,
[templateId]: newResponses,
}));
setSchoolFileTemplates((prevTemplates) => {
return prevTemplates.map((template) =>
template.id === templateId
? { ...template, completed: true, responses: newResponses }
: template
);
});
logger.debug('Formulaire dynamique sauvegardé avec succès');
return Promise.resolve();
} catch (error) {
logger.error('Erreur lors de la soumission du formulaire dynamique:', {
templateId,
error: error.message,
stack: error.stack,
});
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
return Promise.reject(error);
}
};
/**
* Gère les changements de validation des formulaires dynamiques
*/
const handleDynamicFormsValidationChange = (isValid) => {
setIsPage5Valid(isValid);
};
useEffect(() => {
fetchSchoolFileTemplatesFromRegistrationFiles(studentId).then((data) => {
setSchoolFileTemplates(data);
// Récupérer les réponses existantes pour chaque template
const fetchAllResponses = async () => {
const responsesMap = {};
for (const template of data) {
if (template.id) {
try {
const templateData = await fetchFormResponses(template.id);
if (templateData && templateData.formTemplateData) {
if (templateData.formTemplateData.responses) {
responsesMap[template.id] = templateData.formTemplateData.responses;
} else {
// Extraire les réponses depuis les champs
const responses = {};
if (templateData.formTemplateData.fields) {
templateData.formTemplateData.fields.forEach((field) => {
if (
field.type === 'checkbox' &&
field.checked !== undefined
) {
responses[field.id] = field.checked;
} else if (
field.type === 'radio' &&
field.selected !== undefined
) {
responses[field.id] = field.selected;
} else if (
(field.type === 'text' ||
field.type === 'textarea' ||
field.type === 'email') &&
field.value !== undefined
) {
responses[field.id] = field.value;
}
});
}
responsesMap[template.id] = responses;
}
}
} catch (error) {
logger.debug(
`Pas de données existantes pour le template ${template.id}:`,
error
);
}
}
}
setFormResponses(responsesMap);
};
fetchAllResponses();
});
fetchParentFileTemplatesFromRegistrationFiles(studentId).then((data) => {
@ -235,7 +466,7 @@ export default function InscriptionFormShared({
// Fetch data for tuition payment plans
handleTuitionnPaymentPlans();
}
}, [selectedEstablishmentId]);
}, [studentId, selectedEstablishmentId]);
const handleRegistrationPaymentModes = () => {
fetchRegistrationPaymentModes(selectedEstablishmentId)
@ -285,10 +516,22 @@ export default function InscriptionFormShared({
);
}
const updateData = new FormData();
updateData.append('file', file);
// Générer le nom du fichier : <nom_template>.<extension d'origine>
let extension = '';
if (file.name && file.name.lastIndexOf('.') !== -1) {
extension = file.name.substring(file.name.lastIndexOf('.'));
}
// Nettoyer le nom du template pour éviter les caractères spéciaux
const cleanName = (selectedFile.name || 'document')
.replace(/[^a-zA-Z0-9_\-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
const finalFileName = `${cleanName}${extension}`;
return editRegistrationParentFileTemplates(
const updateData = new FormData();
updateData.append('file', file, finalFileName);
return editRegistrationSchoolFileTemplates(
selectedFile.id,
updateData,
csrfToken
@ -299,11 +542,10 @@ export default function InscriptionFormShared({
setUploadedFiles((prev) => {
const updatedFiles = prev.map((uploadedFile) =>
uploadedFile.id === selectedFile.id
? { ...uploadedFile, fileName: response.data.file } // Met à jour le fichier téléversé
? { ...uploadedFile, fileName: response.data.file }
: uploadedFile
);
// Si le fichier n'existe pas encore, l'ajouter
if (!updatedFiles.find((file) => file.id === selectedFile.id)) {
updatedFiles.push({
id: selectedFile.id,
@ -323,11 +565,11 @@ export default function InscriptionFormShared({
)
);
return response; // Retourner la réponse pour signaler le succès
return response;
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du fichier :', error);
throw error; // Relancer l'erreur pour que l'appelant puisse la capturer
throw error;
});
};
@ -358,7 +600,7 @@ export default function InscriptionFormShared({
setUploadedFiles((prev) =>
prev.map((uploadedFile) =>
uploadedFile.id === templateId
? { ...uploadedFile, fileName: null, fileUrl: null } // Réinitialiser les champs liés au fichier
? { ...uploadedFile, fileName: null, fileUrl: null }
: uploadedFile
)
);
@ -423,11 +665,16 @@ export default function InscriptionFormShared({
onSubmit(formDataToSend);
};
const handleNextPage = () => {
const handleNextPage = async () => {
// Sauvegarder avant de passer à l'étape suivante
await saveStepData();
setHasInteracted(false);
setCurrentPage(currentPage + 1);
};
const handlePreviousPage = () => {
const handlePreviousPage = async () => {
// Sauvegarder avant de revenir à l'étape précédente
await saveStepData();
setCurrentPage(currentPage - 1);
};
@ -479,7 +726,18 @@ export default function InscriptionFormShared({
setStep={setCurrentPage}
isStepValid={isStepValid}
/>
<div className="flex-1 h-full mt-12 ">
{/* Indicateur de sauvegarde automatique */}
{enable && (
<AutoSaveIndicator
isSaving={isSaving}
lastSaved={lastSaved}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={() => setAutoSaveEnabled(!autoSaveEnabled)}
/>
)}
<div className="flex-1 h-full mt-6">
{/* Page 1 : Informations sur l'élève */}
{currentPage === 1 && (
<StudentInfoForm
@ -538,86 +796,16 @@ export default function InscriptionFormShared({
</>
)}
{/* Page 5 : Section Fichiers d'inscription */}
{/* Page 5 : Formulaires dynamiques d'inscription */}
{currentPage === 5 && (
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
{/* Liste des états de signature */}
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Documents
</h3>
<ul className="space-y-2">
{schoolFileTemplates.map((template, index) => (
<li
key={template.id}
className={`flex items-center cursor-pointer ${
index === currentTemplateIndex
? 'text-blue-600 font-bold'
: template.file !== null
? 'text-green-600'
: 'text-gray-600'
}`}
onClick={() => setCurrentTemplateIndex(index)} // Mettre à jour l'index du template actuel
>
<span className="mr-2">
{template.file !== null ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<Hourglass className="w-5 h-5 text-gray-600" />
)}
</span>
{template.name || 'Document sans nom'}
</li>
))}
</ul>
</div>
{/* Affichage du fichier actuel */}
<div className="w-3/4">
{currentTemplateIndex < schoolFileTemplates.length && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
{schoolFileTemplates[currentTemplateIndex].name ||
'Document sans nom'}
</h3>
<p className="text-sm text-gray-500 mb-4">
{schoolFileTemplates[currentTemplateIndex].description ||
'Aucune description disponible pour ce document.'}
</p>
{schoolFileTemplates[currentTemplateIndex].file === null ? (
<DocusealForm
key={schoolFileTemplates[currentTemplateIndex].slug}
id="docusealForm"
src={`https://docuseal.com/s/${schoolFileTemplates[currentTemplateIndex].slug}`}
withDownloadButton={false}
withTitle={false}
onComplete={() =>
handleTemplateSigned(currentTemplateIndex)
}
/>
) : (
<iframe
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
title="Document Viewer"
className="w-full"
style={{
height: '75vh',
border: 'none',
}}
/>
)}
</div>
)}
{/* Message de fin */}
{currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center text-green-600 font-semibold">
Tous les formulaires ont été signés avec succès !
</div>
)}
</div>
</div>
<DynamicFormsList
schoolFileTemplates={schoolFileTemplates}
existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange}
enable={enable}
onFileUpload={handleFileUpload}
/>
)}
{/* Dernière page : Section Fichiers parents */}

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import Table from '@/components/Table';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext';
import CheckBox from '@/components/CheckBox';
import CheckBox from '@/components/Form/CheckBox';
const paymentPlansOptions = [
{ id: 1, name: '1 fois', frequency: 1 },

View File

@ -24,8 +24,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
selectedEstablishmentLogo,
setSelectedEstablishmentLogo,
setApiDocuseal
setSelectedEstablishmentLogo
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
@ -41,8 +40,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
user.roles[roleId].establishment__total_capacity;
const establishmentLogo =
user.roles[roleId].establishment__logo;
const establishmentApiDocuseal =
user.roles[roleId].establishment__api_docuseal;
setProfileRole(role);
setSelectedEstablishmentId(establishmentId);
setSelectedEstablishmentEvaluationFrequency(
@ -50,7 +47,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
);
setSelectedEstablishmentTotalCapacity(establishmentTotalCapacity);
setSelectedEstablishmentLogo(establishmentLogo);
setApiDocuseal(establishmentApiDocuseal);
setSelectedRoleId(roleId);
if (onRoleChange) {
onRoleChange(roleId);

View File

@ -0,0 +1,85 @@
import React from 'react';
import { Plus } from 'lucide-react';
// Thèmes couleur selon le type
const THEME = {
groupe: {
bg: 'bg-blue-50',
border: 'border-blue-200',
iconBg: 'bg-blue-100',
icon: 'text-blue-600',
title: 'text-blue-800',
desc: 'text-blue-600',
button: 'bg-blue-500 text-white hover:bg-blue-600',
buttonText: 'text-blue-700',
buttonHover: 'hover:bg-blue-100',
},
formulaire: {
bg: 'bg-emerald-50',
border: 'border-emerald-200',
iconBg: 'bg-emerald-100',
icon: 'text-emerald-600',
title: 'text-emerald-800',
desc: 'text-emerald-600',
button: 'bg-emerald-500 text-white hover:bg-emerald-600',
buttonText: 'text-emerald-700',
buttonHover: 'hover:bg-emerald-100',
},
parent: {
bg: 'bg-orange-50',
border: 'border-orange-200',
iconBg: 'bg-orange-100',
icon: 'text-orange-500',
title: 'text-orange-700',
desc: 'text-orange-600',
button: 'bg-orange-500 text-white hover:bg-orange-600',
buttonText: 'text-orange-700',
buttonHover: 'hover:bg-orange-100',
},
};
const SectionHeaderDocument = ({
icon: Icon,
title,
description,
button = false,
buttonOpeningModal = false,
onClick = null,
className = '',
type = 'groupe', // 'groupe', 'formulaire', 'parent'
}) => {
const theme = THEME[type] || THEME.groupe;
return (
<div className={`flex items-center justify-between border-b ${theme.border} ${theme.bg} px-2 py-3 mb-4 ${className}`}>
<div className="flex items-center gap-3">
{Icon && (
<span className={`${theme.iconBg} p-2 rounded-md flex items-center justify-center`}>
<Icon className={`w-6 h-6 ${theme.icon}`} />
</span>
)}
<div>
<h2 className={`text-lg font-semibold ${theme.title}`}>{title}</h2>
{description && (
<p className={`text-xs ${theme.desc}`}>{description}</p>
)}
</div>
</div>
{button && onClick && (
<button
onClick={onClick}
className={
buttonOpeningModal
? `flex items-center ${theme.button} px-3 py-1 rounded-md shadow transition`
: `flex items-center ${theme.buttonText} ${theme.buttonHover} px-2 py-1 rounded-md`
}
>
<Plus className="w-5 h-5 mr-1" />
<span className="text-sm font-medium">Ajouter</span>
</button>
)}
</div>
);
};
export default SectionHeaderDocument;

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,7 @@ import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useCsrfToken } from '@/context/CsrfContext';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/Form/InputText';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
@ -128,7 +127,6 @@ const TeachersSection = ({
handleEdit,
handleDelete,
}) => {
const csrfToken = useCsrfToken();
const [editingTeacher, setEditingTeacher] = useState(null);
const [newTeacher, setNewTeacher] = useState(null);
const [formData, setFormData] = useState({});
@ -140,40 +138,46 @@ const TeachersSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId, profileRole } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
// --- UTILS ---
// Retourne le profil existant pour un email
const getUsedProfileForEmail = (email) => {
// On cherche tous les profils dont l'email correspond
const matchingProfiles = profiles.filter(p => p.email === email);
// On retourne le premier profil correspondant (ou undefined)
const result = matchingProfiles.length > 0 ? matchingProfiles[0] : undefined;
return result;
};
// Met à jour le formData et newTeacher si besoin
const updateFormData = (data) => {
setFormData(prev => ({ ...prev, ...data }));
if (newTeacher) setNewTeacher(prev => ({ ...prev, ...data }));
};
// Récupération des messages d'erreur pour un champ donné
const getError = (field) => {
return localErrors?.[field]?.[0];
};
// --- HANDLERS ---
const handleEmailChange = (e) => {
const email = e.target.value;
const existingProfile = getUsedProfileForEmail(email);
// Vérifier si l'email correspond à un profil existant
const existingProfile = profiles.find((profile) => profile.email === email);
if (existingProfile) {
logger.info(`Adresse email déjà utilisée pour le profil ${existingProfile.id}`);
}
setFormData((prevData) => ({
...prevData,
updateFormData({
associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null,
}));
if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null,
}));
}
};
const handleCancelConfirmation = () => {
setConfirmPopupVisible(false);
};
// Récupération des messages d'erreur
const getError = (field) => {
return localErrors?.[field]?.[0];
});
};
const handleAddTeacher = () => {
@ -195,15 +199,15 @@ const TeachersSection = ({
};
const handleRemoveTeacher = (id) => {
logger.debug('[DELETE] Suppression teacher id:', id);
return handleDelete(id)
.then(() => {
setTeachers((prevTeachers) =>
prevTeachers.filter((teacher) => teacher.id !== id)
setTeachers(prevTeachers =>
prevTeachers.filter(teacher => teacher.id !== id)
);
logger.debug('[DELETE] Teacher supprimé:', id);
})
.catch((error) => {
logger.error(error);
});
.catch(logger.error);
};
const handleSaveNewTeacher = () => {
@ -234,16 +238,29 @@ const TeachersSection = ({
handleCreate(data)
.then((createdTeacher) => {
// Recherche du profile associé dans profiles
let newProfileId = undefined;
let foundProfile = undefined;
if (
createdTeacher &&
createdTeacher.profile_role &&
createdTeacher.profile
) {
newProfileId = createdTeacher.profile;
foundProfile = profiles.find(p => p.id === newProfileId);
}
setTeachers([createdTeacher, ...teachers]);
setNewTeacher(null);
setLocalErrors({});
setFormData(prev => ({
...prev,
existingProfileId: newProfileId,
}));
})
.catch((error) => {
logger.error('Error:', error.message);
if (error.details) {
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
if (error.details) setLocalErrors(error.details);
});
} else {
setPopupMessage('Tous les champs doivent être remplis et valides');
@ -252,51 +269,24 @@ const TeachersSection = ({
};
const handleUpdateTeacher = (id, updatedData) => {
// Récupérer l'enseignant actuel à partir de la liste des enseignants
const currentTeacher = teachers.find((teacher) => teacher.id === id);
// Vérifier si l'email correspond à un profil existant
const existingProfile = profiles.find(
(profile) => profile.email === currentTeacher.associated_profile_email
);
// Vérifier si l'email a été modifié
const isEmailModified = currentTeacher
? currentTeacher.associated_profile_email !==
updatedData.associated_profile_email
: true;
// Mettre à jour existingProfileId en fonction de l'email
updatedData.existingProfileId = existingProfile ? existingProfile.id : null;
if (
updatedData.last_name &&
updatedData.first_name &&
updatedData.associated_profile_email
) {
const data = {
last_name: updatedData.last_name,
first_name: updatedData.first_name,
profile_role_data: {
id: updatedData.profile_role,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
is_active: true,
...(isEmailModified
? {
profile_data: {
id: updatedData.existingProfileId,
email: updatedData.associated_profile_email,
username: updatedData.associated_profile_email,
password: 'Provisoire01!',
},
}
: { profile: updatedData.existingProfileId }),
},
specialities: updatedData.specialities || [],
const profileRoleData = {
id: updatedData.profile_role,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
profile: updatedData.existingProfileId,
};
handleEdit(id, data)
handleEdit(id, {
last_name: updatedData.last_name,
first_name: updatedData.first_name,
profile_role_data: profileRoleData,
specialities: updatedData.specialities || [],
})
.then((updatedTeacher) => {
setTeachers((prevTeachers) =>
prevTeachers.map((teacher) =>
@ -308,10 +298,7 @@ const TeachersSection = ({
})
.catch((error) => {
logger.error('Error:', error.message);
if (error.details) {
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
if (error.details) setLocalErrors(error.details);
});
} else {
setPopupMessage('Tous les champs doivent être remplis et valides');
@ -321,45 +308,12 @@ const TeachersSection = ({
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
let parsedValue = value;
if (type === 'checkbox') {
parsedValue = checked ? 1 : 0;
}
if (editingTeacher) {
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
[name]: parsedValue,
}));
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
}
let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
updateFormData({ [name]: parsedValue });
};
const handleSpecialitiesChange = (selectedSpecialities) => {
if (editingTeacher) {
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
}
updateFormData({ specialities: selectedSpecialities });
};
const handleEditTeacher = (teacher) => {
@ -406,6 +360,7 @@ const TeachersSection = ({
onChange={handleEmailChange}
placeholder="Adresse email de l'enseignant"
errorMsg={getError('email')}
enable={!isEditing}
/>
);
case 'SPECIALITES':
@ -563,7 +518,7 @@ const TeachersSection = ({
{ name: 'SPECIALITES', label: 'Spécialités' },
{ name: 'ADMINISTRATEUR', label: 'Profil' },
{ name: 'MISE A JOUR', label: 'Mise à jour' },
{ name: 'ACTIONS', label: 'Actions' },
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
];
return (
@ -573,7 +528,7 @@ const TeachersSection = ({
icon={GraduationCap}
title="Liste des enseignants.es"
description="Gérez les enseignants.es de votre école"
button={true}
button={profileRole !== 0}
onClick={handleAddTeacher}
/>
<Table

View File

@ -2,10 +2,7 @@ import React, { useState, useEffect } from 'react';
import {
fetchRegistrationFileGroups,
createRegistrationSchoolFileTemplate,
cloneTemplate,
generateToken,
} from '@/app/actions/registerFileGroupAction';
import { DocusealBuilder } from '@docuseal/react';
import logger from '@/utils/logger';
import MultiSelect from '@/components/Form/MultiSelect'; // Import du composant MultiSelect
import { useCsrfToken } from '@/context/CsrfContext';
@ -13,24 +10,19 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import Popup from '@/components/Popup';
export default function FileUploadDocuSeal({
handleCreateTemplateMaster,
handleEditTemplateMaster,
handleCreateSchoolFileMaster,
handleEditSchoolFileMaster,
fileToEdit = null,
onSuccess,
}) {
const [groups, setGroups] = useState([]);
const [token, setToken] = useState(null);
const [templateMaster, setTemplateMaster] = useState(null);
const [uploadedFileName, setUploadedFileName] = useState('');
const [selectedGroups, setSelectedGroups] = useState([]);
const [guardianDetails, setGuardianDetails] = useState([]);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, user, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId, user } = useEstablishment();
useEffect(() => {
fetchRegistrationFileGroups(selectedEstablishmentId).then((data) =>
@ -47,31 +39,10 @@ export default function FileUploadDocuSeal({
if (!user && !user?.email) {
return;
}
const id = fileToEdit ? fileToEdit.id : null;
generateToken(user?.email, id, selectedEstablishmentId, apiDocuseal)
.then((data) => {
setToken(data.token);
})
.catch((error) =>
logger.error('Erreur lors de la génération du token:', error)
);
}, [fileToEdit]);
const handleGroupChange = (selectedGroups) => {
setSelectedGroups(selectedGroups);
const details = selectedGroups.flatMap((group) =>
group.registration_forms.flatMap((form) =>
form.guardians.map((guardian) => ({
email: guardian.associated_profile_email,
last_name: form.last_name,
first_name: form.first_name,
registration_form: form.student_id,
}))
)
);
setGuardianDetails(details); // Mettre à jour la variable d'état avec les détails des guardians
};
const handleLoad = (detail) => {
@ -104,7 +75,7 @@ export default function FileUploadDocuSeal({
const is_required = data.fields.length > 0;
if (fileToEdit) {
logger.debug('Modification du template master:', templateMaster?.id);
handleEditTemplateMaster({
handleEditSchoolFileMaster({
name: uploadedFileName,
group_ids: selectedGroups.map((group) => group.id),
id: templateMaster?.id,
@ -112,51 +83,12 @@ export default function FileUploadDocuSeal({
});
} else {
logger.debug('Création du template master:', templateMaster?.id);
handleCreateTemplateMaster({
handleCreateSchoolFileMaster({
name: uploadedFileName,
group_ids: selectedGroups.map((group) => group.id),
id: templateMaster?.id,
is_required: is_required,
});
guardianDetails.forEach((guardian, index) => {
logger.debug('creation du clone avec required : ', is_required);
cloneTemplate(
templateMaster?.id,
guardian.email,
is_required,
selectedEstablishmentId,
apiDocuseal
)
.then((clonedDocument) => {
// Sauvegarde des schoolFileTemplates clonés dans la base de données
const data = {
name: `${uploadedFileName}_${guardian.first_name}_${guardian.last_name}`,
slug: clonedDocument.slug,
id: clonedDocument.id,
master: templateMaster?.id,
registration_form: guardian.registration_form,
};
logger.debug('creation : ', data);
createRegistrationSchoolFileTemplate(data, csrfToken)
.then((response) => {
logger.debug('Template enregistré avec succès:', response);
onSuccess();
})
.catch((error) => {
logger.error(
"Erreur lors de l'enregistrement du template:",
error
);
});
// Logique pour envoyer chaque template au submitter
logger.debug('Sending template to:', guardian.email);
})
.catch((error) => {
logger.error('Error during cloning or sending:', error);
});
});
}
};
@ -212,32 +144,7 @@ export default function FileUploadDocuSeal({
{/* Zone de configuration des documents */}
<div className="col-span-8 bg-white p-6 rounded-lg shadow-md border border-gray-200">
{token && (
<div className="h-full overflow-auto">
{/* Description de l'étape */}
<p className="text-gray-700 text-base font-medium mb-4">
Étape 2 - Sélectionnez un document
</p>
<DocusealBuilder
token={token}
headers={{
Authorization: `Bearer ${token}`,
}}
withSendButton={false}
withSignYourselfButton={false}
autosave={false}
withDocumentsList={false}
language={'fr'}
onLoad={handleLoad}
onUpload={handleUpload}
onChange={handleChange}
onSave={handleSubmit}
className="h-full overflow-auto"
style={{ maxHeight: '65vh' }}
/>
</div>
)}
</div>
</div>
)}

View File

@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import Modal from '@/components/Modal';
import logger from '@/utils/logger';
import { Edit3, Trash2, Plus } from 'lucide-react';
function ParentFileForm({ initialData, groups, onSubmit, onCancel }) {
const [name, setName] = useState(initialData?.name || '');
const [description, setDescription] = useState(initialData?.description || '');
// Correction : s'assurer que selectedGroups ne contient que des IDs uniques
const [selectedGroups, setSelectedGroups] = useState(
Array.isArray(initialData?.groups)
? Array.from(
new Set(
initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g))
)
)
: []
);
const [isRequired, setIsRequired] = useState(initialData?.is_required || false);
useEffect(() => {
if (initialData) {
setName(initialData.name || '');
setDescription(initialData.description || '');
setSelectedGroups(
Array.isArray(initialData.groups)
? Array.from(
new Set(
initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g))
)
)
: []
);
setIsRequired(initialData.is_required || false);
}
}, [initialData]);
const handleSubmit = (e) => {
e.preventDefault();
if (!name || selectedGroups.length === 0) return;
const data = {
name,
description,
groups: selectedGroups,
is_required: isRequired,
id: initialData?.id,
};
logger.debug('[ParentFileForm] handleSubmit data:', data);
onSubmit(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom de la pièce <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
required
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dossiers d&apos;inscription <span className="text-red-500">*</span>
</label>
<select
multiple
value={selectedGroups}
onChange={e =>
setSelectedGroups(
Array.from(new Set(Array.from(e.target.selectedOptions, opt => Number(opt.value))))
)
}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500"
required
>
{groups.map(group => (
<option key={`group-option-${group.id}`} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_required"
checked={isRequired}
onChange={e => setIsRequired(e.target.checked)}
className="mr-2"
/>
<label htmlFor="is_required" className="text-sm text-gray-700">
Obligatoire
</label>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
className="px-4 py-2 rounded-md bg-gray-200 text-gray-700 hover:bg-gray-300"
onClick={onCancel}
>
Annuler
</button>
<button
type="submit"
className="bg-orange-600 text-white px-4 py-2 rounded-md hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
disabled={!name || selectedGroups.length === 0}
>
{initialData?.id ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
);
}
export default function ParentFiles({
parentFiles,
groups,
handleCreate,
handleEdit,
handleDelete,
singleForm = false,
initialData = null,
onCancel,
}) {
const [isModalOpen, setIsModalOpen] = useState(singleForm);
const [editingFile, setEditingFile] = useState(initialData);
useEffect(() => {
if (singleForm) {
setIsModalOpen(true);
setEditingFile(initialData);
}
}, [singleForm, initialData]);
const openCreateModal = () => {
setEditingFile(null);
setIsModalOpen(true);
};
const openEditModal = (file) => {
setEditingFile(file);
setIsModalOpen(true);
};
const closeModal = () => {
setEditingFile(null);
setIsModalOpen(false);
if (onCancel) onCancel();
};
const handleFormSubmit = (data) => {
logger.debug('[ParentFiles] handleFormSubmit data:', data);
if (editingFile && editingFile.id) {
logger.debug('[ParentFiles] handleEdit called with:', data.id, data);
handleEdit(data.id, data).then(closeModal);
} else {
logger.debug('[ParentFiles] handleCreate called with:', data);
handleCreate(data).then(closeModal);
}
};
if (singleForm) {
return (
<ParentFileForm
initialData={editingFile}
groups={groups}
onSubmit={handleFormSubmit}
onCancel={closeModal}
/>
);
}
return (
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-orange-700">Pièces à fournir</h2>
<button
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded shadow"
onClick={openCreateModal}
>
<Plus className="w-5 h-5" />
<span>Ajouter une pièce</span>
</button>
</div>
<table className="min-w-full border border-gray-200 rounded bg-white">
<thead>
<tr className="bg-orange-50">
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Nom</th>
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Description</th>
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Dossiers</th>
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Obligatoire</th>
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Actions</th>
</tr>
</thead>
<tbody>
{parentFiles.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-6 text-gray-400">Aucune pièce à fournir</td>
</tr>
) : (
parentFiles.map((file) => (
<tr key={file.id} className="hover:bg-orange-50">
<td className="px-3 py-2 border-b">{file.name}</td>
<td className="px-3 py-2 border-b">{file.description}</td>
<td className="px-3 py-2 border-b">
{(file.groups || []).map(
gid => groups.find(g => g.id === gid)?.name || gid
).join(', ')}
</td>
<td className="px-3 py-2 border-b text-center">
{file.is_required ? (
<span className="bg-green-100 text-green-700 px-2 py-1 rounded text-xs font-semibold">Oui</span>
) : (
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">Non</span>
)}
</td>
<td className="px-3 py-2 border-b text-center">
<button
className="text-blue-500 hover:text-blue-700 mr-2"
onClick={() => openEditModal(file)}
>
<Edit3 className="w-5 h-5" />
</button>
<button
className="text-red-500 hover:text-red-700"
onClick={() => handleDelete(file.id)}
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
<Modal
isOpen={isModalOpen}
setIsOpen={closeModal}
title={editingFile ? 'Modifier la pièce à fournir' : 'Créer une pièce à fournir'}
modalClassName="w-full max-w-md"
>
<ParentFileForm
initialData={editingFile}
groups={groups}
onSubmit={handleFormSubmit}
onCancel={closeModal}
/>
</Modal>
</div>
);
}

View File

@ -1,16 +1,13 @@
import React, { useState } from 'react';
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
import Table from '@/components/Table';
import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/Form/MultiSelect';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
import { useCsrfToken } from '@/context/CsrfContext';
import SectionHeader from '@/components/SectionHeader';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage';
import Popup from '@/components/Popup';
import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/Form/MultiSelect';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
export default function ParentFilesSection({
parentFiles,
@ -18,6 +15,11 @@ export default function ParentFilesSection({
handleCreate,
handleEdit,
handleDelete,
hideCreateButton = false,
tableContainerClass = '',
headerClassName = '',
TableComponent,
SectionHeaderComponent,
}) {
const [editingDocumentId, setEditingDocumentId] = useState(null);
const [formData, setFormData] = useState(null);
@ -325,27 +327,30 @@ export default function ParentFilesSection({
},
];
// Ajout : écouteur d'event global pour déclencher la création depuis la popup centrale
React.useEffect(() => {
if (!hideCreateButton) return;
const handler = () => handleAddEmptyRequiredDocument();
window.addEventListener('parentFilesSection:create', handler);
return () => window.removeEventListener('parentFilesSection:create', handler);
}, [hideCreateButton]);
const Table = TableComponent || ((props) => <div />); // fallback
const SectionHeader = SectionHeaderComponent || ((props) => <div />);
return (
<div className="mt-12 w-4/5">
<div className={`w-full h-full flex flex-col ${tableContainerClass}`}>
<SectionHeader
icon={FileText}
title="Pièces à fournir"
description="Configurez la liste des documents que les parents doivent fournir."
button={true}
onClick={handleAddEmptyRequiredDocument}
className={headerClassName}
/>
<Table
data={
editingDocumentId === 'new' ? [formData, ...parentFiles] : parentFiles
}
columns={columnsRequiredDocuments}
emptyMessage={
<AlertMessage
type="warning"
title="Aucune pièce à fournir enregistrée"
message="Veuillez procéder à la création de nouvelles pièces à fournir par les parents"
/>
}
emptyMessage="Aucune pièce à fournir enregistrée"
/>
<Popup
isOpen={removePopupVisible}

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react';
import InputText from '@/components/Form/InputText';
import Button from '@/components/Form/Button';
export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
const [name, setName] = useState('');
@ -18,38 +20,28 @@ export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom du groupe
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Utilisation de InputText pour le nom du groupe */}
<InputText
label="Nom du groupe"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<InputText
label="Description"
name="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
<div className="flex justify-end">
<button
<Button
primary
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{initialData ? 'Modifier le groupe' : 'Créer le groupe'}
</button>
text="Enregistrer"
/>
</div>
</form>
);

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import CheckBox from '@/components/CheckBox';
import CheckBox from '@/components/Form/CheckBox';
import InputText from '@/components/Form/InputText';
import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import CheckBox from '@/components/CheckBox';
import CheckBox from '@/components/Form/CheckBox';
import InputText from '@/components/Form/InputText';
import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';

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;

View File

@ -46,10 +46,6 @@ export const EstablishmentProvider = ({ children }) => {
const storedUser = sessionStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
});
const [apiDocuseal, setApiDocusealState] = useState(() => {
const storedApiDocuseal = sessionStorage.getItem('apiDocuseal');
return storedApiDocuseal ? JSON.parse(storedApiDocuseal) : null;
});
const [selectedEstablishmentLogo, setSelectedEstablishmentLogoState] = useState(() => {
const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo');
return storedLogo ? JSON.parse(storedLogo) : null;
@ -94,11 +90,6 @@ export const EstablishmentProvider = ({ children }) => {
sessionStorage.setItem('user', JSON.stringify(user));
};
const setApiDocuseal = (api) => {
setApiDocusealState(api);
sessionStorage.setItem('apiDocuseal', JSON.stringify(api));
};
const setSelectedEstablishmentLogo = (logo) => {
setSelectedEstablishmentLogoState(logo);
sessionStorage.setItem('selectedEstablishmentLogo', JSON.stringify(logo));
@ -122,7 +113,6 @@ export const EstablishmentProvider = ({ children }) => {
name: role.establishment__name,
evaluation_frequency: role.establishment__evaluation_frequency,
total_capacity: role.establishment__total_capacity,
api_docuseal: role.establishment__api_docuseal,
logo: role.establishment__logo,
role_id: i,
role_type: role.role_type,
@ -143,9 +133,6 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedEstablishmentTotalCapacity(
userEstablishments[roleIndexDefault].total_capacity
);
setApiDocuseal(
userEstablishments[roleIndexDefault].api_docuseal
);
setSelectedEstablishmentLogo(
userEstablishments[roleIndexDefault].logo
);
@ -168,7 +155,6 @@ export const EstablishmentProvider = ({ children }) => {
setUserState(null);
setSelectedEstablishmentEvaluationFrequencyState(null);
setSelectedEstablishmentTotalCapacityState(null);
setApiDocusealState(null);
setSelectedEstablishmentLogoState(null);
sessionStorage.clear();
};
@ -184,8 +170,6 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedEstablishmentEvaluationFrequency,
selectedEstablishmentTotalCapacity,
setSelectedEstablishmentTotalCapacity,
apiDocuseal,
setApiDocuseal,
selectedEstablishmentLogo,
setSelectedEstablishmentLogo,
selectedRoleId,

View File

@ -1,41 +0,0 @@
import logger from '@/utils/logger';
import { BE_DOCUSEAL_CLONE_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'POST') {
const { templateId, email, is_required, establishment_id, apiDocuseal } = req.body;
fetch(BE_DOCUSEAL_CLONE_TEMPLATE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': apiDocuseal,
},
body: JSON.stringify({
templateId,
email,
is_required,
establishment_id,
}),
})
.then((response) => {
if (!response.ok) {
return response.json().then((err) => {
throw new Error(err.message);
});
}
return response.json();
})
.then((data) => {
logger.debug('Template cloned successfully:', data);
res.status(200).json(data);
})
.catch((error) => {
logger.error('Error cloning template:', error);
res.status(500).json({ error: 'Internal Server Error' });
});
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -1,35 +0,0 @@
import logger from '@/utils/logger';
import { BE_DOCUSEAL_DOWNLOAD_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'GET') {
const { slug, establishment_id, apiDocuseal } = req.query;
logger.debug('slug : ', slug);
fetch(`${BE_DOCUSEAL_DOWNLOAD_TEMPLATE}/${slug}?establishment_id=${establishment_id}`, {
method: 'GET',
headers: {
'X-Auth-Token': apiDocuseal,
},
})
.then((response) => {
if (!response.ok) {
return response.json().then((err) => {
throw new Error(err.message);
});
}
return response.json();
})
.then((data) => {
logger.debug('Template downloaded successfully:', data);
res.status(200).json(data);
})
.catch((error) => {
logger.error('Error downloading template:', error);
res.status(500).json({ error: 'Internal Server Error' });
});
} else {
res.setHeader('Allow', ['GET']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -1,34 +0,0 @@
import logger from '@/utils/logger';
import { BE_DOCUSEAL_GET_JWT } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'POST') {
const { apiDocuseal, ...rest } = req.body;
fetch(BE_DOCUSEAL_GET_JWT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': apiDocuseal,
},
body: JSON.stringify(rest),
})
.then((response) => {
logger.debug('Response status:', response.status);
return response
.json()
.then((data) => ({ status: response.status, data }));
})
.then(({ status, data }) => {
logger.debug('Response data:', data);
res.status(status).json(data);
})
.catch((error) => {
logger.error('Error:', error);
res.status(500).json({ error: 'Internal Server Error' });
});
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

Some files were not shown because too many files have changed in this diff Show More