9 Commits

84 changed files with 2736 additions and 969 deletions

View File

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

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

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

View File

@ -349,13 +349,6 @@ SIMPLE_JWT = {
'TOKEN_TYPE_CLAIM': 'token_type', '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 # Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application' ASGI_APPLICATION = 'N3wtSchool.asgi.application'

View File

@ -46,7 +46,6 @@ urlpatterns = [
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')), path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')), path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
path("School/", include(("School.urls", 'School'), namespace='School')), 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("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')), path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')), path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),

View File

@ -277,6 +277,16 @@ class RegistrationForm(models.Model):
return "RF_" + self.student.last_name + "_" + self.student.first_name return "RF_" + self.student.last_name + "_" + self.student.first_name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# 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
# Vérifier si un fichier existant doit être remplacé # Vérifier si un fichier existant doit être remplacé
if self.pk: # Si l'objet existe déjà dans la base de données if self.pk: # Si l'objet existe déjà dans la base de données
try: try:
@ -290,16 +300,27 @@ class RegistrationForm(models.Model):
# Appeler la méthode save originale # Appeler la méthode save originale
super().save(*args, **kwargs) 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 ######################## ####################### MASTER FILES ########################
############################################################# #############################################################
####### DocuSeal masters (documents école, à signer ou pas) ####### ####### Formulaires masters (documents école, à signer ou pas) #######
class RegistrationSchoolFileMaster(models.Model): class RegistrationSchoolFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True) groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
id = models.IntegerField(primary_key=True)
name = models.CharField(max_length=255, default="") name = models.CharField(max_length=255, default="")
is_required = models.BooleanField(default=False) is_required = models.BooleanField(default=False)
formMasterData = models.JSONField(default=list, blank=True, null=True)
def __str__(self): def __str__(self):
return f'{self.group.name} - {self.id}' return f'{self.group.name} - {self.id}'
@ -321,14 +342,14 @@ def registration_school_file_upload_to(instance, filename):
def registration_parent_file_upload_to(instance, filename): def registration_parent_file_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}" return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
####### DocuSeal templates (par dossier d'inscription) ####### ####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model): class RegistrationSchoolFileTemplate(models.Model):
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True) 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="") slug = models.CharField(max_length=255, default="")
name = 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) registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to) file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True)
def __str__(self): def __str__(self):
return self.name return self.name

View File

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

View File

@ -21,8 +21,161 @@ from PyPDF2 import PdfMerger
import shutil import shutil
import logging import logging
import json
from django.http import QueryDict
from rest_framework.response import Response
from rest_framework import status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
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)
"""
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,
)
created = []
# 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):
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
for m in school_masters:
exists = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
if exists:
continue
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
tmpl = RegistrationSchoolFileTemplate.objects.create(
master=m,
registration_form=register_form,
name=m.name or "",
formTemplateData=m.formMasterData or [],
slug=slug,
)
created.append(tmpl)
logger.info("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(): def recupereListeFichesInscription():
""" """
Retourne la liste complète des fiches dinscription. Retourne la liste complète des fiches dinscription.

View File

@ -1,14 +1,24 @@
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf from .register_form_views import (
from .registration_file_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, RegistrationSchoolFileMasterView,
RegistrationSchoolFileMasterSimpleView, RegistrationSchoolFileMasterSimpleView,
RegistrationSchoolFileTemplateView,
RegistrationSchoolFileTemplateSimpleView,
RegistrationParentFileMasterView, RegistrationParentFileMasterView,
RegistrationParentFileMasterSimpleView, RegistrationParentFileMasterSimpleView,
RegistrationParentFileTemplateSimpleView, RegistrationParentFileTemplateSimpleView,
RegistrationParentFileTemplateView RegistrationParentFileTemplateView
) )
from .registration_school_file_templates_views import (
RegistrationSchoolFileTemplateView,
RegistrationSchoolFileTemplateSimpleView,
)
from .registration_file_group_views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group from .registration_file_group_views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
from .student_views import StudentView, StudentListView, ChildrenListView, search_students from .student_views import StudentView, StudentListView, ChildrenListView, search_students
from .guardian_views import GuardianView, DissociateGuardianView from .guardian_views import GuardianView, DissociateGuardianView
@ -33,7 +43,7 @@ __all__ = [
'RegistrationFileGroupSimpleView', 'RegistrationFileGroupSimpleView',
'get_registration_files_by_group', 'get_registration_files_by_group',
'get_school_file_templates_by_rf', 'get_school_file_templates_by_rf',
'get_parent_file_templates_by_rf' 'get_parent_file_templates_by_rf',
'StudentView', 'StudentView',
'StudentListView', 'StudentListView',
'ChildrenListView', 'ChildrenListView',

View File

@ -0,0 +1,363 @@
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
import json
from django.http import QueryDict
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import (
RegistrationForm,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate
)
from N3wtSchool import bdd
import logging
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationSchoolFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser]
@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):
@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:
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:
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:
master.delete()
return JsonResponse({'message': 'La suppression du master de template 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)
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

@ -5,12 +5,19 @@ from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
import json
from django.http import QueryDict
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
from N3wtSchool import bdd from N3wtSchool import bdd
import logging
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationSchoolFileMasterView(APIView): class RegistrationSchoolFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné", operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
manual_parameters=[ manual_parameters=[
@ -45,10 +52,19 @@ class RegistrationSchoolFileMasterView(APIView):
} }
) )
def post(self, request): def post(self, request):
serializer = RegistrationSchoolFileMasterSerializer(data=request.data) 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(): if serializer.is_valid():
serializer.save() obj = serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED) 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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileMasterSimpleView(APIView): class RegistrationSchoolFileMasterSimpleView(APIView):
@ -78,11 +94,19 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
def put(self, request, id): def put(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id) master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None: if master is None:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) 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)
# 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(): if serializer.is_valid():
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) 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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema( @swagger_auto_schema(
@ -96,7 +120,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id) master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is not None: if master is not None:
master.delete() 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) return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
else: else:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

Binary file not shown.

View File

@ -1,5 +1,6 @@
import subprocess import subprocess
import os import os
from watchfiles import run_process
def run_command(command): def run_command(command):
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -11,6 +12,7 @@ def run_command(command):
return process.returncode return process.returncode
test_mode = os.getenv('test_mode', 'false').lower() == 'true' test_mode = os.getenv('test_mode', 'false').lower() == 'true'
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
commands = [ commands = [
["python", "manage.py", "collectstatic", "--noinput"], ["python", "manage.py", "collectstatic", "--noinput"],
@ -32,23 +34,55 @@ test_commands = [
["python", "manage.py", "init_mock_datas"] ["python", "manage.py", "init_mock_datas"]
] ]
for command in commands: def run_daphne():
if run_command(command) != 0: try:
exit(1) result = subprocess.run([
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
])
return result.returncode
except KeyboardInterrupt:
print("Arrêt de Daphne (KeyboardInterrupt)")
return 0
#if test_mode: if __name__ == "__main__":
# for test_command in test_commands: for command in commands:
# if run_command(test_command) != 0: if run_command(command) != 0:
# exit(1) exit(1)
# Lancer les processus en parallèle #if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
processes = [ if watch_mode:
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]), celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]), celery_beat = subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"]) try:
] run_process(
'.',
# Attendre la fin des processus target=run_daphne
for process in processes: )
process.wait() except KeyboardInterrupt:
print("Arrêt demandé (KeyboardInterrupt)")
finally:
celery_worker.terminate()
celery_beat.terminate()
celery_worker.wait()
celery_beat.wait()
else:
processes = [
subprocess.Popen([
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
]
try:
for process in processes:
process.wait()
except KeyboardInterrupt:
print("Arrêt demandé (KeyboardInterrupt)")
for process in processes:
process.terminate()
for process in processes:
process.wait()

View File

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

View File

@ -1,14 +1,13 @@
{ {
"name": "n3wt-school-front-end", "name": "n3wt-school-front-end",
"version": "0.0.1", "version": "0.0.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n3wt-school-front-end", "name": "n3wt-school-front-end",
"version": "0.0.1", "version": "0.0.3",
"dependencies": { "dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -29,6 +28,7 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.62.0",
"react-international-phone": "^4.5.0", "react-international-phone": "^4.5.0",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"react-tooltip": "^5.28.0" "react-tooltip": "^5.28.0"
@ -536,11 +536,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true "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": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -8834,6 +8829,21 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-international-phone": { "node_modules/react-international-phone": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz", "resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
@ -11253,11 +11263,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true "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": { "@eslint-community/eslint-utils": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -17160,6 +17165,12 @@
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
} }
}, },
"react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"requires": {}
},
"react-international-phone": { "react-international-phone": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz", "resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",

View File

@ -14,7 +14,6 @@
"test:coverage": "jest --coverage" "test:coverage": "jest --coverage"
}, },
"dependencies": { "dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -35,19 +34,20 @@
"react-dnd": "^16.0.1", "react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1", "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.62.0",
"react-international-phone": "^4.5.0", "react-international-phone": "^4.5.0",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"react-tooltip": "^5.28.0" "react-tooltip": "^5.28.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.11", "eslint-config-next": "14.2.11",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14" "tailwindcss": "^3.4.14"
} }

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import AcademicResults from '@/components/Grades/AcademicResults'; import AcademicResults from '@/components/Grades/AcademicResults';
import Attendance from '@/components/Grades/Attendance'; import Attendance from '@/components/Grades/Attendance';
import Remarks from '@/components/Grades/Remarks'; import Remarks from '@/components/Grades/Remarks';
@ -9,7 +9,7 @@ import Homeworks from '@/components/Grades/Homeworks';
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations'; import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
import Orientation from '@/components/Grades/Orientation'; import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle'; import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { import {
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
@ -29,7 +29,7 @@ import { useClasses } from '@/context/ClassesContext';
import { Award, FileText } from 'lucide-react'; import { Award, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart'; import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import GradeView from '@/components/Grades/GradeView'; import GradeView from '@/components/Grades/GradeView';
import { import {
fetchStudentCompetencies, fetchStudentCompetencies,

View File

@ -36,7 +36,6 @@ export default function DashboardPage() {
const { const {
selectedEstablishmentId, selectedEstablishmentId,
selectedEstablishmentTotalCapacity, selectedEstablishmentTotalCapacity,
apiDocuseal,
} = useEstablishment(); } = useEstablishment();
const [statusDistribution, setStatusDistribution] = useState([ const [statusDistribution, setStatusDistribution] = useState([
@ -165,25 +164,6 @@ export default function DashboardPage() {
return ( return (
<div key={selectedEstablishmentId} className="p-6"> <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 */} {/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard <StatCard

View File

@ -2,9 +2,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Tab from '@/components/Tab'; import Tab from '@/components/Tab';
import TabContent from '@/components/TabContent'; import TabContent from '@/components/TabContent';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import InputText from '@/components/InputText'; 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 logger from '@/utils/logger';
import { import {
fetchSmtpSettings, fetchSmtpSettings,

View File

@ -8,9 +8,9 @@ import { fetchClasse } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import CheckBox from '@/components/CheckBox'; import CheckBox from '@/components/Form/CheckBox';
import { import {
fetchAbsences, fetchAbsences,
createAbsences, createAbsences,

View File

@ -52,7 +52,7 @@ export default function Page() {
); );
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
@ -353,7 +353,6 @@ export default function Page() {
<FilesGroupsManagement <FilesGroupsManagement
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal={apiDocuseal}
/> />
</div> </div>
), ),

View File

@ -2,17 +2,17 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { User, Mail } from 'lucide-react'; import { User, Mail } from 'lucide-react';
import InputTextIcon from '@/components/InputTextIcon'; import InputTextIcon from '@/components/Form/InputTextIcon';
import ToggleSwitch from '@/components/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import Table from '@/components/Table'; import Table from '@/components/Table';
import FeesSection from '@/components/Structure/Tarification/FeesSection'; import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection'; import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import SectionTitle from '@/components/SectionTitle'; import SectionTitle from '@/components/SectionTitle';
import InputPhone from '@/components/InputPhone'; import InputPhone from '@/components/Form/InputPhone';
import CheckBox from '@/components/CheckBox'; import CheckBox from '@/components/Form/CheckBox';
import RadioList from '@/components/RadioList'; import RadioList from '@/components/Form/RadioList';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date'; import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -35,7 +35,6 @@ import {
fetchRegistrationFileGroups, fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters, fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters, fetchRegistrationParentFileMasters,
cloneTemplate,
createRegistrationSchoolFileTemplate, createRegistrationSchoolFileTemplate,
createRegistrationParentFileTemplate, createRegistrationParentFileTemplate,
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
@ -96,7 +95,7 @@ export default function CreateSubscriptionPage() {
const { getNiveauLabel } = useClasses(); const { getNiveauLabel } = useClasses();
const formDataRef = useRef(formData); const formDataRef = useRef(formData);
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const router = useRouter(); const router = useRouter();
@ -522,128 +521,23 @@ export default function CreateSubscriptionPage() {
} else { } else {
// Création du dossier d'inscription // Création du dossier d'inscription
createRegisterForm(data, csrfToken) createRegisterForm(data, csrfToken)
.then((data) => { .then((response) => {
// Clonage des schoolFileTemplates showNotification(
const masters = schoolFileMasters.filter((file) => "Dossier d'inscription créé avec succès",
file.groups.includes(selectedFileGroup) 'success',
'Succès'
); );
const parentMasters = parentFileMasters.filter((file) => router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
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);
});
})
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);
logger.error('Erreur lors de la mise à jour du dossier:', error);
showNotification( showNotification(
"Erreur lors de la création du dossier d'inscription", "Erreur lors de la création du dossier d'inscription",
'error', 'error',
'Erreur', 'Erreur',
'ERR_ADM_SUB_01' '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 [formErrors, setFormErrors] = useState({});
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => { const handleSubmit = (data) => {
@ -59,7 +59,6 @@ export default function Page() {
studentId={studentId} studentId={studentId}
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit} onSubmit={handleSubmit}
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL} cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
errors={formErrors} errors={formErrors}

View File

@ -40,8 +40,8 @@ import {
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { PhoneLabel } from '@/components/PhoneLabel'; import { PhoneLabel } from '@/components/Form/PhoneLabel';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import FilesModal from '@/components/Inscription/FilesModal'; import FilesModal from '@/components/Inscription/FilesModal';
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date'; import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
@ -250,7 +250,12 @@ export default function Page({ params: { locale } }) {
}, 500); // Debounce la recherche }, 500); // Debounce la recherche
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [searchTerm, selectedEstablishmentId, currentSchoolYearPage, itemsPerPage]); }, [
searchTerm,
selectedEstablishmentId,
currentSchoolYearPage,
itemsPerPage,
]);
/** /**
* UseEffect to update page count of tab * UseEffect to update page count of tab

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import {
CalendarDays, CalendarDays,
} from 'lucide-react'; } from 'lucide-react';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url'; import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
import { import {
fetchChildren, fetchChildren,

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';

View File

@ -3,9 +3,9 @@ import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon'; import InputTextIcon from '@/components/Form/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Button'; // Importez le composant Button import Button from '@/components/Form/Button'; // Importez le composant Button
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url'; import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url';
import { login } from '@/app/actions/authAction'; import { login } from '@/app/actions/authAction';
@ -35,11 +35,7 @@ export default function Page() {
logger.debug('Sign In Result', result); logger.debug('Sign In Result', result);
if (result.error) { if (result.error) {
showNotification( showNotification(result.error, 'error', 'Erreur');
result.error,
'error',
'Erreur'
);
setIsLoading(false); setIsLoading(false);
} else { } else {
// On initialise le contexte establishement avec la session // On initialise le contexte establishement avec la session
@ -50,11 +46,7 @@ export default function Page() {
if (url) { if (url) {
router.push(url); router.push(url);
} else { } else {
showNotification( showNotification('Type de rôle non géré', 'error', 'Erreur');
'Type de rôle non géré',
'error',
'Erreur'
);
} }
}); });
setIsLoading(false); setIsLoading(false);

View File

@ -3,9 +3,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import InputTextIcon from '@/components/InputTextIcon'; import InputTextIcon from '@/components/Form/InputTextIcon';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url'; import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
@ -25,25 +25,13 @@ export default function Page() {
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
if (data.message !== '') { if (data.message !== '') {
showNotification( showNotification(data.message, 'success', 'Succès');
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`); router.push(`${FE_USERS_LOGIN_URL}`);
} else { } else {
if (data.errorMessage) { if (data.errorMessage) {
showNotification( showNotification(data.errorMessage, 'error', 'Erreur');
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) { } else if (data.errorFields) {
showNotification( showNotification(data.errorFields.email, 'error', 'Erreur');
data.errorFields.email,
'error',
'Erreur'
);
} }
} }
setIsLoading(false); setIsLoading(false);

View File

@ -5,9 +5,9 @@ import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon'; import InputTextIcon from '@/components/Form/InputTextIcon';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import { FE_USERS_LOGIN_URL } from '@/utils/Url'; import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { KeySquare } from 'lucide-react'; import { KeySquare } from 'lucide-react';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
@ -33,21 +33,12 @@ export default function Page() {
resetPassword(uuid, data, csrfToken) resetPassword(uuid, data, csrfToken)
.then((data) => { .then((data) => {
if (data.message !== '') { if (data.message !== '') {
logger.debug('Success:', data); logger.debug('Success:', data);
showNotification( showNotification(data.message, 'success', 'Succès');
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`); router.push(`${FE_USERS_LOGIN_URL}`);
} else { } else {
if (data.errorMessage) { if (data.errorMessage) {
showNotification( showNotification(data.errorMessage, 'error', 'Erreur');
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) { } else if (data.errorFields) {
showNotification( showNotification(
data.errorFields.password1 || data.errorFields.password2, data.errorFields.password1 || data.errorFields.password2,

View File

@ -4,9 +4,9 @@ import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon'; import InputTextIcon from '@/components/Form/InputTextIcon';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import { User, KeySquare } from 'lucide-react'; import { User, KeySquare } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url'; import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
@ -36,22 +36,16 @@ export default function Page() {
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
if (data.message !== '') { if (data.message !== '') {
showNotification( showNotification(data.message, 'success', 'Succès');
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`); router.push(`${FE_USERS_LOGIN_URL}`);
} else { } else {
if (data.errorMessage) { if (data.errorMessage) {
showNotification( showNotification(data.errorMessage, 'error', 'Erreur');
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) { } else if (data.errorFields) {
showNotification( showNotification(
data.errorFields.email || data.errorFields.password1 || data.errorFields.password2, data.errorFields.email ||
data.errorFields.password1 ||
data.errorFields.password2,
'error', 'error',
'Erreur' 'Erreur'
); );

View File

@ -3,11 +3,7 @@ import {
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL, BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL, BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL, BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_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
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { errorHandler, requestResponseHandler } from './actionsHandlers';
@ -327,62 +323,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

@ -9,10 +9,10 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import RecipientInput from '@/components/RecipientInput'; import RecipientInput from '@/components/RecipientInput';
import { useRouter } from 'next/navigation'; // Ajoute cette ligne import { useRouter } from 'next/navigation'; // Ajoute cette ligne
import WisiwigTextArea from '@/components/WisiwigTextArea'; import WisiwigTextArea from '@/components/Form/WisiwigTextArea';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
export default function EmailSender({ csrfToken }) { export default function EmailSender({ csrfToken }) {
const [recipients, setRecipients] = useState([]); const [recipients, setRecipients] = useState([]);

View File

@ -0,0 +1,589 @@
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';
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,
validation: {
pattern: '',
minLength: '',
maxLength: '',
},
};
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,
validation: defaultValues.validation,
});
}
}, [isOpen, editingField, reset]);
// Ajouter une option au select
const addOption = () => {
if (newOption.trim()) {
setCurrentField({
...currentField,
options: [...currentField.options, newOption.trim()],
});
setNewOption('');
}
};
// Supprimer une option du select
const removeOption = (index) => {
const newOptions = currentField.options.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) => {
onChange(e.target.value);
setCurrentField({
...currentField,
type: e.target.value,
});
}}
choices={FIELD_TYPES}
placeHolder="Sélectionner un type"
required
/>
)}
/>
{![
'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">
{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">
{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 === '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, handleChange,
fieldName, fieldName,
itemLabelFunc = () => null, itemLabelFunc = () => null,
horizontal, horizontal = false,
}) => { }) => {
// Vérifier si formData[fieldName] est un tableau ou une valeur booléenne // Vérifier si formData[fieldName] est un tableau ou une valeur booléenne
const isChecked = Array.isArray(formData[fieldName]) const isChecked = Array.isArray(formData[fieldName])
@ -22,7 +22,7 @@ const CheckBox = ({
{horizontal && ( {horizontal && (
<label <label
htmlFor={`${fieldName}-${item.id}`} 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)} {itemLabelFunc(item)}
</label> </label>
@ -40,7 +40,7 @@ const CheckBox = ({
{!horizontal && ( {!horizontal && (
<label <label
htmlFor={`${fieldName}-${item.id}`} 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)} {itemLabelFunc(item)}
</label> </label>

View File

@ -0,0 +1,443 @@
import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form';
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';
/*
* Récupère une icône Lucide par son nom.
*/
export function getIcon(name) {
if (Object.keys(LucideIcons).includes(name)) {
const Icon = LucideIcons[name];
return Icon ?? null;
} else {
return null;
}
}
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,
csrfToken,
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel)
}) {
const {
handleSubmit,
control,
formState: { errors },
reset,
} = useForm();
// 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);
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)
);
});
if (hasFiles) {
// Utiliser FormData pour l'envoi de fichiers
const formData = new FormData();
// Ajouter l'ID du formulaire
formData.append('formId', formConfig.id.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 {
// 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,
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}`);
}
logger.debug('=== FIN onSubmit ===');
};
const onError = (errors) => {
logger.error('=== ERREURS DE VALIDATION ===');
logger.error('Erreurs :', errors);
alert('Erreurs de validation : ' + JSON.stringify(errors, null, 2));
};
return (
<form
onSubmit={handleSubmit(onSubmit, onError)}
className="max-w-md mx-auto"
>
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
<h2 className="text-2xl font-bold text-center mb-4">
{formConfig.title}
</h2>
{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' ||
field.type === 'date') && (
<Controller
name={field.id}
control={control}
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}
required={field.required}
IconItem={field.icon ? getIcon(field.icon) : null}
type={field.type}
name={name}
value={value || ''}
onChange={onChange}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{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}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<SelectChoice
label={field.label}
required={field.required}
name={name}
selected={value || ''}
callback={onChange}
choices={field.options.map((e) => ({ label: e, value: e }))}
placeHolder={`Sélectionner ${field.label.toLowerCase()}`}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{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}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<WisiwigTextArea
label={field.label}
placeholder={field.placeholder}
value={value || ''}
onChange={onChange}
required={field.required}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
</div>
))}
<div className="form-group-submit mt-4">
<Button
type="submit"
primary
text={formConfig.submitLabel ? 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>
</form>
);
}

View File

@ -0,0 +1,616 @@
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 { 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,
} from 'lucide-react';
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 },
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() {
const [formConfig, setFormConfig] = useState({
id: 0,
title: 'Nouveau formulaire',
submitLabel: 'Envoyer',
fields: [],
});
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
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();
// 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)
? 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;
}
setSaving(true);
setSaveMessage({ type: '', text: '' });
try {
// Simulation d'envoi au backend (à remplacer par l'appel API réel)
// const response = await fetch('/api/form-templates', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify(formConfig),
// });
// if (!response.ok) {
// throw new Error('Erreur lors de l\'enregistrement du formulaire');
// }
// const data = await response.json();
// Simulation d'une réponse du backend
await new Promise((resolve) => setTimeout(resolve, 1000));
setSaveMessage({
type: 'success',
text: 'Formulaire enregistré avec succès',
});
// Si le backend renvoie un ID, on peut mettre à jour l'ID du formulaire
// setFormConfig({ ...formConfig, id: data.id });
} catch (error) {
setSaveMessage({
type: 'error',
text:
error.message || "Une erreur est survenue lors de l'enregistrement",
});
} finally {
setSaving(false);
}
};
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,
})
}
/>
</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);
setShowAddFieldModal(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);
setShowAddFieldModal(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] : null
}
editingIndex={editingIndex}
/>
{/* 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,19 @@
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: '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 from 'react';
export default function InputTextIcon({ export default function InputTextIcon({
name, name,
type, type,
@ -31,9 +33,11 @@ export default function InputTextIcon({
!enable ? 'bg-gray-100 cursor-not-allowed' : '' !enable ? 'bg-gray-100 cursor-not-allowed' : ''
}`} }`}
> >
<span className="inline-flex min-h-9 items-center px-3 rounded-l-md bg-gray-50 text-gray-500 text-sm"> {IconItem ? (
{IconItem && <IconItem />} <span className="inline-flex min-h-9 items-center px-3 rounded-l-md bg-gray-50 text-gray-500 text-sm">
</span> <IconItem />
</span>
) : null}
<input <input
type={type} type={type}
id={name} id={name}

View File

@ -4,10 +4,10 @@ import 'react-quill/dist/quill.snow.css';
const ReactQuill = dynamic(() => import('react-quill'), { ssr: false }); const ReactQuill = dynamic(() => import('react-quill'), { ssr: false });
export default function WisiwigTextArea({ export default function WisiwigTextArea({
label = 'Mail', label = 'Zone de Texte',
value, value,
onChange, onChange,
placeholder = 'Ecrivez votre mail ici...', placeholder = 'Ecrivez votre texte ici...',
className = 'h-64', className = 'h-64',
required = false, required = false,
errorMsg, errorMsg,

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import ToggleSwitch from '@/components/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';

View File

@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react'; import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react';
import RadioList from '@/components/RadioList'; import RadioList from '@/components/Form/RadioList';
const LEVELS = [ const LEVELS = [
{ value: 0, label: 'Non évalué' }, { value: 0, label: 'Non évalué' },

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { Upload, Eye, Trash2, FileText } from 'lucide-react'; import { Upload, Eye, Trash2, FileText } from 'lucide-react';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';

View File

@ -1,6 +1,6 @@
// Import des dépendances nécessaires // Import des dépendances nécessaires
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { import {
fetchSchoolFileTemplatesFromRegistrationFiles, fetchSchoolFileTemplatesFromRegistrationFiles,
@ -220,9 +220,7 @@ export default function InscriptionFormShared({
.then((data) => { .then((data) => {
setProfiles(data); setProfiles(data);
}) })
.catch((error) => .catch((error) => logger.error('Error fetching profiles : ', error));
logger.error('Error fetching profiles : ', error)
);
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
// Fetch data for registration payment modes // Fetch data for registration payment modes

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import RadioList from '@/components/RadioList'; import RadioList from '@/components/Form/RadioList';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export default function PaymentMethodSelector({ export default function PaymentMethodSelector({

View File

@ -1,5 +1,5 @@
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import InputPhone from '@/components/InputPhone'; import InputPhone from '@/components/Form/InputPhone';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Trash2, Plus, Users } from 'lucide-react'; import { Trash2, Plus, Users } from 'lucide-react';

View File

@ -1,4 +1,4 @@
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Trash2, Plus, Users } from 'lucide-react'; import { Trash2, Plus, Users } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { fetchRegisterForm } from '@/app/actions/subscriptionAction'; import { fetchRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import { levels, genders } from '@/utils/constants'; import { levels, genders } from '@/utils/constants';
@ -112,13 +112,10 @@ export default function StudentInfoForm({
(field === 'birth_place' && (field === 'birth_place' &&
(!formData.birth_place || formData.birth_place.trim() === '')) || (!formData.birth_place || formData.birth_place.trim() === '')) ||
(field === 'birth_postal_code' && (field === 'birth_postal_code' &&
( (!formData.birth_postal_code ||
!formData.birth_postal_code ||
String(formData.birth_postal_code).trim() === '' || String(formData.birth_postal_code).trim() === '' ||
isNaN(Number(formData.birth_postal_code)) || isNaN(Number(formData.birth_postal_code)) ||
!Number.isInteger(Number(formData.birth_postal_code)) !Number.isInteger(Number(formData.birth_postal_code)))) ||
)
) ||
(field === 'address' && (field === 'address' &&
(!formData.address || formData.address.trim() === '')) || (!formData.address || formData.address.trim() === '')) ||
(field === 'attending_physician' && (field === 'attending_physician' &&

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import ToggleSwitch from '@/components/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import { import {
fetchSchoolFileTemplatesFromRegistrationFiles, fetchSchoolFileTemplatesFromRegistrationFiles,
@ -10,7 +10,7 @@ import {
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react'; import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
export default function ValidateSubscription({ export default function ValidateSubscription({
studentId, studentId,

View File

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

View File

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

View File

@ -2,9 +2,9 @@ import React, { useState, useRef, useCallback } from 'react';
import TreeView from '@/components/Structure/Competencies/TreeView'; import TreeView from '@/components/Structure/Competencies/TreeView';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import { Award, CheckCircle } from 'lucide-react'; import { Award, CheckCircle } from 'lucide-react';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import CheckBox from '@/components/CheckBox'; import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Button'; import Button from '@/components/Form/Button';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { import {
fetchEstablishmentCompetencies, fetchEstablishmentCompetencies,

View File

@ -2,10 +2,10 @@ import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import TeacherItem from '@/components/Structure/Configuration/TeacherItem'; import TeacherItem from '@/components/Structure/Configuration/TeacherItem';
import MultiSelect from '@/components/MultiSelect'; import MultiSelect from '@/components/Form/MultiSelect';
import LevelLabel from '@/components/CustomLabels/LevelLabel'; import LevelLabel from '@/components/CustomLabels/LevelLabel';
import { DndProvider, useDrop } from 'react-dnd'; import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';

View File

@ -2,7 +2,7 @@ import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputTextWithColorIcon from '@/components/InputTextWithColorIcon'; import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';

View File

@ -2,11 +2,11 @@ import React, { useState, useEffect } from 'react';
import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react'; import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
import TeacherItem from './TeacherItem'; import TeacherItem from './TeacherItem';
import logger from '@/utils/logger'; import logger from '@/utils/logger';

View File

@ -2,12 +2,9 @@ import React, { useState, useEffect } from 'react';
import { import {
fetchRegistrationFileGroups, fetchRegistrationFileGroups,
createRegistrationSchoolFileTemplate, createRegistrationSchoolFileTemplate,
cloneTemplate,
generateToken,
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
import { DocusealBuilder } from '@docuseal/react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import MultiSelect from '@/components/MultiSelect'; // Import du composant MultiSelect import MultiSelect from '@/components/Form/MultiSelect'; // Import du composant MultiSelect
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -19,18 +16,13 @@ export default function FileUploadDocuSeal({
onSuccess, onSuccess,
}) { }) {
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [token, setToken] = useState(null);
const [templateMaster, setTemplateMaster] = useState(null); const [templateMaster, setTemplateMaster] = useState(null);
const [uploadedFileName, setUploadedFileName] = useState(''); const [uploadedFileName, setUploadedFileName] = useState('');
const [selectedGroups, setSelectedGroups] = useState([]); const [selectedGroups, setSelectedGroups] = useState([]);
const [guardianDetails, setGuardianDetails] = useState([]);
const [popupVisible, setPopupVisible] = useState(false); const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState(''); const [popupMessage, setPopupMessage] = useState('');
const csrfToken = useCsrfToken(); const { selectedEstablishmentId, user } = useEstablishment();
const { selectedEstablishmentId, user, apiDocuseal } = useEstablishment();
useEffect(() => { useEffect(() => {
fetchRegistrationFileGroups(selectedEstablishmentId).then((data) => fetchRegistrationFileGroups(selectedEstablishmentId).then((data) =>
@ -47,31 +39,10 @@ export default function FileUploadDocuSeal({
if (!user && !user?.email) { if (!user && !user?.email) {
return; 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]); }, [fileToEdit]);
const handleGroupChange = (selectedGroups) => { const handleGroupChange = (selectedGroups) => {
setSelectedGroups(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) => { const handleLoad = (detail) => {
@ -118,39 +89,6 @@ export default function FileUploadDocuSeal({
id: templateMaster?.id, id: templateMaster?.id,
is_required: is_required, 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);
});
});
} }
}; };
@ -206,32 +144,7 @@ export default function FileUploadDocuSeal({
{/* Zone de configuration des documents */} {/* Zone de configuration des documents */}
<div className="col-span-8 bg-white p-6 rounded-lg shadow-md border border-gray-200"> <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>
</div> </div>
)} )}

View File

@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { Download, Edit3, Trash2, FolderPlus, Signature, AlertTriangle } from 'lucide-react'; import { Download, Edit3, Trash2, FolderPlus, Signature, AlertTriangle } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import Table from '@/components/Table'; import Table from '@/components/Table';
import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import { import {
// GET // GET
@ -21,9 +20,7 @@ import {
// DELETE // DELETE
deleteRegistrationFileGroup, deleteRegistrationFileGroup,
deleteRegistrationSchoolFileMaster, deleteRegistrationSchoolFileMaster,
deleteRegistrationParentFileMaster, deleteRegistrationParentFileMaster
removeTemplate
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm'; import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -33,11 +30,11 @@ import Popup from '@/components/Popup';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal';
export default function FilesGroupsManagement({ export default function FilesGroupsManagement({
csrfToken, csrfToken,
selectedEstablishmentId, selectedEstablishmentId
apiDocuseal
}) { }) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]); const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]); const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
@ -116,72 +113,24 @@ export default function FilesGroupsManagement({
); );
setRemovePopupOnConfirm(() => () => { setRemovePopupOnConfirm(() => () => {
setIsLoading(true); setIsLoading(true);
// Supprimer les clones associés via l'API DocuSeal // Supprimer le template master de la base de données
const removeClonesPromises = [ deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
...schoolFileTemplates .then((response) => {
.filter((template) => template.master === templateMaster.id) if (response.ok) {
.map((template) => setSchoolFileMasters(
removeTemplate(template.id, selectedEstablishmentId, apiDocuseal) schoolFileMasters.filter(
), (fichier) => fichier.id !== templateMaster.id
removeTemplate(templateMaster.id, selectedEstablishmentId, apiDocuseal), )
]; );
showNotification(
`Le document "${templateMaster.name}" a été correctement supprimé.`,
'success',
'Succès'
);
// Attendre que toutes les suppressions dans DocuSeal soient terminées setRemovePopupVisible(false);
Promise.all(removeClonesPromises) setIsLoading(false);
.then((responses) => { } else {
const allSuccessful = responses.every((response) => response && response.id);
if (allSuccessful) {
logger.debug('Master et clones supprimés avec succès de DocuSeal.');
// Supprimer le template master de la base de données
deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
.then((response) => {
if (response.ok) {
setSchoolFileMasters(
schoolFileMasters.filter(
(fichier) => fichier.id !== templateMaster.id
)
);
showNotification(
`Le document "${templateMaster.name}" a été correctement supprimé.`,
'success',
'Succès'
);
setRemovePopupVisible(false);
setIsLoading(false);
} else {
showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
setIsLoading(false);
}
})
.catch((error) => {
logger.error('Error deleting file from database:', error);
showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
setIsLoading(false);
});
} else {
showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
setIsLoading(false);
}
})
.catch((error) => {
logger.error('Error removing template from DocuSeal:', error);
showNotification( showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`, `Erreur lors de la suppression du document "${templateMaster.name}".`,
'error', 'error',
@ -189,7 +138,18 @@ export default function FilesGroupsManagement({
); );
setRemovePopupVisible(false); setRemovePopupVisible(false);
setIsLoading(false); setIsLoading(false);
}); }
})
.catch((error) => {
logger.error('Error deleting file from database:', error);
showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
setIsLoading(false);
});
}); });
}; };
@ -542,25 +502,13 @@ export default function FilesGroupsManagement({
icon={Signature} icon={Signature}
title="Formulaires à remplir" title="Formulaires à remplir"
description="Gérez les formulaires nécessitant une signature électronique." description="Gérez les formulaires nécessitant une signature électronique."
button={apiDocuseal} button={true}
buttonOpeningModal={true} buttonOpeningModal={true}
onClick={() => { onClick={() => {
setIsModalOpen(true); setIsModalOpen(true);
setIsEditing(false); setIsEditing(false);
}} }}
/> />
<div className="mb-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
!apiDocuseal && 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{!apiDocuseal && (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{!apiDocuseal && 'Clé API Docuseal manquante'}
</span>
</div>
<Table <Table
data={filteredFiles} data={filteredFiles}
columns={columnsFiles} columns={columnsFiles}

View File

@ -1,14 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react'; import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import InputText from '@/components/InputText'; import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/MultiSelect'; import MultiSelect from '@/components/Form/MultiSelect';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction'; import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import ToggleSwitch from '@/components/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';

View File

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

View File

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

View File

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

View File

@ -1,34 +0,0 @@
import logger from '@/utils/logger';
import { BE_DOCUSEAL_REMOVE_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'DELETE') {
const { templateId, establishment_id, apiDocuseal } = req.body;
fetch(`${BE_DOCUSEAL_REMOVE_TEMPLATE}/${templateId}?establishment_id=${establishment_id}`, {
method: 'DELETE',
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 removed successfully:', data);
res.status(200).json(data);
})
.catch((error) => {
logger.error('Error removing template:', error);
res.status(500).json({ error: 'Internal Server Error' });
});
} else {
res.setHeader('Allow', ['DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -4,12 +4,6 @@ export const WS_BASE_URL = process.env.NEXT_PUBLIC_WSAPI_URL;
//URL-Back-End //URL-Back-End
// GESTION DocuSeal
export const BE_DOCUSEAL_GET_JWT = `${BASE_URL}/DocuSeal/generateToken`;
export const BE_DOCUSEAL_CLONE_TEMPLATE = `${BASE_URL}/DocuSeal/cloneTemplate`;
export const BE_DOCUSEAL_REMOVE_TEMPLATE = `${BASE_URL}/DocuSeal/removeTemplate`;
export const BE_DOCUSEAL_DOWNLOAD_TEMPLATE = `${BASE_URL}/DocuSeal/downloadTemplate`;
// GESTION LOGIN // GESTION LOGIN
export const BE_AUTH_NEW_PASSWORD_URL = `${BASE_URL}/Auth/newPassword`; export const BE_AUTH_NEW_PASSWORD_URL = `${BASE_URL}/Auth/newPassword`;
export const BE_AUTH_REGISTER_URL = `${BASE_URL}/Auth/subscribe`; export const BE_AUTH_REGISTER_URL = `${BASE_URL}/Auth/subscribe`;
@ -131,12 +125,6 @@ export const FE_PARENTS_HOME_URL = '/parents';
export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie'; export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie';
export const FE_PARENTS_EDIT_SUBSCRIPTION_URL = '/parents/editSubscription'; export const FE_PARENTS_EDIT_SUBSCRIPTION_URL = '/parents/editSubscription';
// API DOCUSEAL
export const FE_API_DOCUSEAL_GENERATE_TOKEN = '/api/docuseal/generateToken';
export const FE_API_DOCUSEAL_CLONE_URL = '/api/docuseal/cloneTemplate';
export const FE_API_DOCUSEAL_DOWNLOAD_URL = '/api/docuseal/downloadTemplate';
export const FE_API_DOCUSEAL_DELETE_URL = '/api/docuseal/removeTemplate';
/** /**
* Fonction pour obtenir l'URL de redirection en fonction du rôle * Fonction pour obtenir l'URL de redirection en fonction du rôle
* @param {RIGHTS} role * @param {RIGHTS} role

View File

@ -10,10 +10,10 @@ services:
database: database:
image: "postgres:latest" image: "postgres:latest"
expose: ports:
- 5432 - "5432:5432"
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View File

@ -0,0 +1,94 @@
# 🧭 Premiers Pas avec N3WT-SCHOOL
Bienvenue dans **N3WT-SCHOOL** !
Ce guide rapide vous accompagnera dans les premières étapes de configuration de votre instance afin de la rendre pleinement opérationnelle pour votre établissement.
## ✅ Étapes à suivre :
1. **Configurer la signature électronique des documents via Docuseal**
2. **Activer l'envoi d'e-mails depuis la plateforme**
---
## ✍️ 1. Configuration de la signature électronique (Docuseal)
Afin de permettre la signature électronique des documents administratifs (inscriptions, conventions, etc.), N3WT-SCHOOL s'appuie sur [**Docuseal**](https://docuseal.com), un service sécurisé de signature électronique.
### Étapes :
1. Connectez-vous ou créez un compte sur Docuseal :
👉 [https://docuseal.com/sign_in](https://docuseal.com/sign_in)
2. Une fois connecté, accédez à la section API :
👉 [https://console.docuseal.com/api](https://console.docuseal.com/api)
3. Copiez votre **X-Auth-Token** personnel.
Ce jeton permettra à N3WT-SCHOOL de se connecter à votre compte Docuseal.
4. **Envoyez votre X-Auth-Token à l'équipe N3WT-SCHOOL** pour qu'un administrateur puisse finaliser la configuration :
✉️ Contact : [contact@n3wtschool.com](mailto:contact@n3wtschool.com)
> ⚠️ Cette opération doit impérativement être réalisée par un administrateur N3WT-SCHOOL.
> Ne partagez pas ce token en dehors de ce cadre.
---
## 📧 2. Configuration de l'envoi de-mails
Lenvoi de mails depuis N3WT-SCHOOL est requis pour :
- Notifications aux étudiants
- Accusés de réception
- Envoi de documents (factures, conventions…)
Vous devrez renseigner les informations de votre fournisseur SMTP dans **Paramètres > E-mail** de lapplication.
### Informations requises :
- Hôte SMTP
- Port SMTP
- Type de sécurité (TLS / SSL)
- Adresse e-mail (utilisateur SMTP)
- Mot de passe ou **mot de passe applicatif**
---
## 🔐 Mot de passe applicatif (Gmail, Outlook, etc.)
Certains fournisseurs (notamment **Gmail**, **Yahoo**, **iCloud**) ne permettent pas dutiliser directement votre mot de passe personnel pour des applications tierces.
Vous devez créer un **mot de passe applicatif**.
### Exemple : Créer un mot de passe applicatif avec Gmail
1. Connectez-vous à [votre compte Google](https://myaccount.google.com)
2. Allez dans **Sécurité > Validation en 2 étapes**
3. Activez la validation en 2 étapes si ce nest pas déjà fait
4. Ensuite, allez dans **Mots de passe des applications**
5. Sélectionnez une application (ex. : "Autre (personnalisée)") et nommez-la "N3WT-SCHOOL"
6. Copiez le mot de passe généré et utilisez-le comme **mot de passe SMTP**
> 📎 Vous pouvez consulter laide officielle de Google ici :
> [Créer un mot de passe dapplication Google](https://support.google.com/accounts/answer/185833)
---
## 🗂️ Configuration SMTP — Fournisseurs courants
| Fournisseur | SMTP Host | Port TLS | Port SSL | Sécurité | Lien aide SMTP |
| ----------------- | ------------------- | -------- | -------- | -------- | ---------------------------------------------------------------------------- |
| Gmail | smtp.gmail.com | 587 | 465 | TLS/SSL | [Aide SMTP Gmail](https://support.google.com/mail/answer/7126229?hl=fr) |
| Outlook / Hotmail | smtp.office365.com | 587 | — | TLS | [Aide SMTP Outlook](https://support.microsoft.com/fr-fr/office) |
| Yahoo Mail | smtp.mail.yahoo.com | 587 | 465 | TLS/SSL | [Aide SMTP Yahoo](https://help.yahoo.com/kb/SLN4724.html) |
| iCloud Mail | smtp.mail.me.com | 587 | 465 | TLS/SSL | [Aide iCloud SMTP](https://support.apple.com/fr-fr/HT202304) |
| OVH | ssl0.ovh.net | 587 | 465 | TLS/SSL | [Aide OVH SMTP](https://help.ovhcloud.com/csm/fr-email-general-settings) |
| Infomaniak | mail.infomaniak.com | 587 | 465 | TLS/SSL | [Aide SMTP Infomaniak](https://www.infomaniak.com/fr/support/faq/1817) |
| Gandi | mail.gandi.net | 587 | 465 | TLS/SSL | [Aide SMTP Gandi](https://docs.gandi.net/fr/mail/faq/envoyer_des_mails.html) |
> 📝 Si votre fournisseur ne figure pas dans cette liste, n'hésitez pas à contacter votre fournisseur de mail pour obtenir ces informations.
---
## 🎉 Vous êtes prêt·e !
Une fois ces deux configurations effectuées, votre instance N3WT-SCHOOL est prête à fonctionner pleinement.
Vous pourrez ensuite ajouter vos formations, étudiants, documents et automatiser toute votre gestion scolaire.