mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-29 07:53:23 +00:00
Compare commits
13 Commits
develop
...
abb4b525b2
| Author | SHA1 | Date | |
|---|---|---|---|
| abb4b525b2 | |||
| b4f70e6bad | |||
| 8549699dec | |||
| a034149eae | |||
| 12f5fc7aa9 | |||
| 2dc0dfa268 | |||
| dd00cba385 | |||
| 7486f6c5ce | |||
| 1e5bc6ccba | |||
| 0fb668b212 | |||
| 5e62ee5100 | |||
| e89d2fc4c3 | |||
| 9481a0132d |
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
# This file is intentionally left blank to make this directory a Python package.
|
|
||||||
@ -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')
|
|
||||||
]
|
|
||||||
@ -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)
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
|
import Establishment.models
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ class Migration(migrations.Migration):
|
|||||||
('licence_code', models.CharField(blank=True, max_length=100)),
|
('licence_code', models.CharField(blank=True, max_length=100)),
|
||||||
('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)),
|
||||||
|
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Establishment', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='establishment',
|
|
||||||
name='api_docuseal',
|
|
||||||
field=models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-31 09:56
|
|
||||||
|
|
||||||
import Establishment.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Establishment', '0002_establishment_api_docuseal'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='establishment',
|
|
||||||
name='logo',
|
|
||||||
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -14,6 +16,39 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Conversation',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
||||||
|
('file_url', models.URLField(blank=True, null=True)),
|
||||||
|
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_edited', models.BooleanField(default=False)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Messagerie',
|
name='Messagerie',
|
||||||
fields=[
|
fields=[
|
||||||
@ -27,4 +62,40 @@ class Migration(migrations.Migration):
|
|||||||
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserPresence',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
||||||
|
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConversationParticipant',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
||||||
|
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('conversation', 'participant')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageRead',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('read_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
||||||
|
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('message', 'participant')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:40
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('GestionMessagerie', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Conversation',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Message',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('content', models.TextField()),
|
|
||||||
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
|
||||||
('file_url', models.URLField(blank=True, null=True)),
|
|
||||||
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
|
||||||
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('is_edited', models.BooleanField(default=False)),
|
|
||||||
('is_deleted', models.BooleanField(default=False)),
|
|
||||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
|
||||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='UserPresence',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
|
||||||
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ConversationParticipant',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
|
||||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('conversation', 'participant')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MessageRead',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('read_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
|
||||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('message', 'participant')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hostSMTP": "",
|
|
||||||
"portSMTP": 25,
|
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"useSSL": false,
|
|
||||||
"useTLS": false
|
|
||||||
}
|
|
||||||
@ -17,9 +17,12 @@ def getConnection(id_establishement):
|
|||||||
try:
|
try:
|
||||||
# Récupérer l'instance de l'établissement
|
# Récupérer l'instance de l'établissement
|
||||||
establishment = Establishment.objects.get(id=id_establishement)
|
establishment = Establishment.objects.get(id=id_establishement)
|
||||||
|
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Récupérer les paramètres SMTP associés à l'établissement
|
# Récupérer les paramètres SMTP associés à l'établissement
|
||||||
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
||||||
|
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
|
||||||
|
|
||||||
# Créer une connexion SMTP avec les paramètres récupérés
|
# Créer une connexion SMTP avec les paramètres récupérés
|
||||||
connection = get_connection(
|
connection = get_connection(
|
||||||
@ -32,9 +35,11 @@ def getConnection(id_establishement):
|
|||||||
)
|
)
|
||||||
return connection
|
return connection
|
||||||
except SMTPSettings.DoesNotExist:
|
except SMTPSettings.DoesNotExist:
|
||||||
|
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
|
||||||
# Aucun paramètre SMTP spécifique, retournera None
|
# Aucun paramètre SMTP spécifique, retournera None
|
||||||
return None
|
return None
|
||||||
except Establishment.DoesNotExist:
|
except Establishment.DoesNotExist:
|
||||||
|
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||||
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||||
|
|
||||||
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
||||||
@ -53,11 +58,13 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
plain_message = strip_tags(message)
|
plain_message = strip_tags(message)
|
||||||
if connection is not None:
|
if connection is not None:
|
||||||
from_email = username
|
from_email = username
|
||||||
|
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
|
||||||
else:
|
else:
|
||||||
from_email = settings.EMAIL_HOST_USER
|
from_email = settings.EMAIL_HOST_USER
|
||||||
|
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
|
||||||
|
|
||||||
logger.info(f"From email: {from_email}")
|
logger.info(f"From email: {from_email}")
|
||||||
|
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
|
||||||
|
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@ -79,6 +86,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
||||||
|
logger.error(f"Settings : {connection}")
|
||||||
|
logger.error(f"Settings : {connection}")
|
||||||
logger.error(f"Type d'erreur: {type(e)}")
|
logger.error(f"Type d'erreur: {type(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
|||||||
@ -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'
|
||||||
|
|
||||||
|
|||||||
@ -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')),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"activationMailRelance": "Oui",
|
|
||||||
"delaiRelance": "30",
|
|
||||||
"ambiances": [
|
|
||||||
"2-3 ans",
|
|
||||||
"3-6 ans",
|
|
||||||
"6-12 ans"
|
|
||||||
],
|
|
||||||
"genres": [
|
|
||||||
"Fille",
|
|
||||||
"Garçon"
|
|
||||||
],
|
|
||||||
"modesPaiement": [
|
|
||||||
"Chèque",
|
|
||||||
"Virement",
|
|
||||||
"Prélèvement SEPA"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
0
Back-End/Subscriptions/management/__init__.py
Normal file
0
Back-End/Subscriptions/management/__init__.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Management command pour tester la configuration email Django
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from N3wtSchool.mailManager import getConnection, sendMail
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Test de la configuration email'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--establishment-id', type=int, help='ID de l\'établissement pour test')
|
||||||
|
parser.add_argument('--email', type=str, default='test@example.com', help='Email de destination')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("=== Test de configuration email ===")
|
||||||
|
|
||||||
|
# Affichage de la configuration
|
||||||
|
self.stdout.write(f"EMAIL_HOST: {settings.EMAIL_HOST}")
|
||||||
|
self.stdout.write(f"EMAIL_PORT: {settings.EMAIL_PORT}")
|
||||||
|
self.stdout.write(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}")
|
||||||
|
self.stdout.write(f"EMAIL_HOST_PASSWORD: {settings.EMAIL_HOST_PASSWORD}")
|
||||||
|
self.stdout.write(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}")
|
||||||
|
self.stdout.write(f"EMAIL_USE_SSL: {settings.EMAIL_USE_SSL}")
|
||||||
|
self.stdout.write(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}")
|
||||||
|
|
||||||
|
# Test 1: Configuration par défaut Django
|
||||||
|
self.stdout.write("\n--- Test : Configuration EMAIL par défaut ---")
|
||||||
|
try:
|
||||||
|
result = send_mail(
|
||||||
|
'Test Django Email',
|
||||||
|
'Ceci est un test de la configuration email par défaut.',
|
||||||
|
settings.EMAIL_HOST_USER,
|
||||||
|
[options['email']],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"✅ Email envoyé avec succès (résultat: {result})"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f"❌ Erreur: {e}"))
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import Subscriptions.models
|
import Subscriptions.models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -46,10 +46,11 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RegistrationSchoolFileTemplate',
|
name='RegistrationSchoolFileTemplate',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('slug', models.CharField(default='', max_length=255)),
|
('slug', models.CharField(default='', max_length=255)),
|
||||||
('name', models.CharField(default='', max_length=255)),
|
('name', models.CharField(default='', max_length=255)),
|
||||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||||
|
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
@ -153,7 +154,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(default='', max_length=255)),
|
('name', models.CharField(default='', max_length=255)),
|
||||||
('description', models.CharField(blank=True, null=True)),
|
('description', models.CharField(blank=True, max_length=500, null=True)),
|
||||||
('is_required', models.BooleanField(default=False)),
|
('is_required', models.BooleanField(default=False)),
|
||||||
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||||
],
|
],
|
||||||
@ -161,9 +162,10 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RegistrationSchoolFileMaster',
|
name='RegistrationSchoolFileMaster',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(default='', max_length=255)),
|
('name', models.CharField(default='', max_length=255)),
|
||||||
('is_required', models.BooleanField(default=False)),
|
('is_required', models.BooleanField(default=False)),
|
||||||
|
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||||
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
|
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Subscriptions', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='registrationparentfilemaster',
|
|
||||||
name='description',
|
|
||||||
field=models.CharField(blank=True, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -2,11 +2,9 @@ from django.db import models
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger("SubscriptionModels")
|
logger = logging.getLogger("SubscriptionModels")
|
||||||
|
|
||||||
@ -277,6 +275,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,19 +298,180 @@ 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) #######
|
||||||
|
def registration_school_file_master_upload_to(instance, filename):
|
||||||
|
# Stocke les fichiers masters dans un dossier dédié
|
||||||
|
# Utilise l'ID si le nom n'est pas encore disponible
|
||||||
|
est_name = None
|
||||||
|
if instance.establishment and instance.establishment.name:
|
||||||
|
est_name = instance.establishment.name
|
||||||
|
else:
|
||||||
|
# fallback si pas d'établissement (devrait être rare)
|
||||||
|
est_name = "unknown_establishment"
|
||||||
|
return f"{est_name}/Formulaires/{filename}"
|
||||||
|
|
||||||
class RegistrationSchoolFileMaster(models.Model):
|
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)
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to=registration_school_file_master_upload_to,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
|
||||||
|
)
|
||||||
|
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.group.name} - {self.id}'
|
return f'{self.name} - {self.id}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_url(self):
|
||||||
|
if self.file and hasattr(self.file, 'url'):
|
||||||
|
return self.file.url
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
affected_rf_ids = set()
|
||||||
|
is_new = self.pk is None
|
||||||
|
|
||||||
|
# Log création ou modification du master
|
||||||
|
if is_new:
|
||||||
|
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
|
||||||
|
else:
|
||||||
|
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
|
||||||
|
|
||||||
|
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||||
|
if old.file and old.file.name:
|
||||||
|
old_filename = os.path.basename(old.file.name)
|
||||||
|
# Nouveau nom selon le type (dynamique ou existant)
|
||||||
|
if (
|
||||||
|
self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
):
|
||||||
|
new_filename = f"{self.name}.pdf"
|
||||||
|
else:
|
||||||
|
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||||
|
extension = os.path.splitext(old_filename)[1]
|
||||||
|
new_filename = f"{self.name}{extension}" if extension else self.name
|
||||||
|
if new_filename and old_filename != new_filename:
|
||||||
|
old_file_path = old.file.path
|
||||||
|
if os.path.exists(old_file_path):
|
||||||
|
try:
|
||||||
|
os.remove(old_file_path)
|
||||||
|
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
|
||||||
|
# Correction du nom du fichier pour éviter le suffixe random
|
||||||
|
if (
|
||||||
|
not self.formMasterData
|
||||||
|
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
|
||||||
|
):
|
||||||
|
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
|
||||||
|
if self.file and self.file.name:
|
||||||
|
current_filename = os.path.basename(self.file.name)
|
||||||
|
current_path = self.file.path
|
||||||
|
expected_filename = new_filename
|
||||||
|
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
|
||||||
|
if current_filename != expected_filename:
|
||||||
|
try:
|
||||||
|
if os.path.exists(current_path):
|
||||||
|
os.rename(current_path, expected_path)
|
||||||
|
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
|
||||||
|
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
|
||||||
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||||
|
if (
|
||||||
|
self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
):
|
||||||
|
from Subscriptions.util import generate_form_json_pdf
|
||||||
|
pdf_filename = f"{self.name}.pdf"
|
||||||
|
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||||
|
self.file.save(pdf_filename, pdf_file, save=False)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
|
||||||
|
try:
|
||||||
|
# Import local pour éviter le circular import
|
||||||
|
from Subscriptions.util import create_templates_for_registration_form
|
||||||
|
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
|
||||||
|
# Détermination des RF concernés
|
||||||
|
if is_new:
|
||||||
|
new_groups = set(self.groups.values_list('id', flat=True))
|
||||||
|
affected_rf_ids.update(
|
||||||
|
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||||
|
old_groups = set(old.groups.values_list('id', flat=True))
|
||||||
|
new_groups = set(self.groups.values_list('id', flat=True))
|
||||||
|
affected_rf_ids.update(
|
||||||
|
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
|
||||||
|
)
|
||||||
|
form_data_changed = (
|
||||||
|
old.formMasterData != self.formMasterData
|
||||||
|
and self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
)
|
||||||
|
name_changed = old.name != self.name
|
||||||
|
if form_data_changed or name_changed:
|
||||||
|
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
|
||||||
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Pour chaque RF concerné, régénérer les templates
|
||||||
|
for rf_id in affected_rf_ids:
|
||||||
|
try:
|
||||||
|
rf = RegistrationForm.objects.get(pk=rf_id)
|
||||||
|
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
|
||||||
|
create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
|
||||||
|
# Import local pour éviter le circular import
|
||||||
|
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||||
|
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
|
||||||
|
for tmpl in templates:
|
||||||
|
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
|
||||||
|
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
|
||||||
|
try:
|
||||||
|
self.file.delete(save=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
####### Parent files masters (documents à fournir par les parents) #######
|
####### Parent files masters (documents à fournir par les parents) #######
|
||||||
class RegistrationParentFileMaster(models.Model):
|
class RegistrationParentFileMaster(models.Model):
|
||||||
@ -316,19 +485,19 @@ class RegistrationParentFileMaster(models.Model):
|
|||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
def registration_school_file_upload_to(instance, filename):
|
def registration_school_file_upload_to(instance, filename):
|
||||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}"
|
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
|
||||||
|
|
||||||
def registration_parent_file_upload_to(instance, filename):
|
def registration_parent_file_upload_to(instance, filename):
|
||||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
return f"registration_files/{instance.registration_form.establishment.name}/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
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import (
|
from .models import (
|
||||||
RegistrationFileGroup,
|
RegistrationFileGroup,
|
||||||
RegistrationForm,
|
RegistrationForm,
|
||||||
Student,
|
Student,
|
||||||
Guardian,
|
Guardian,
|
||||||
Sibling,
|
Sibling,
|
||||||
Language,
|
Language,
|
||||||
RegistrationSchoolFileMaster,
|
RegistrationSchoolFileMaster,
|
||||||
RegistrationSchoolFileTemplate,
|
RegistrationSchoolFileTemplate,
|
||||||
RegistrationParentFileMaster,
|
RegistrationParentFileMaster,
|
||||||
RegistrationParentFileTemplate,
|
RegistrationParentFileTemplate,
|
||||||
AbsenceManagement,
|
AbsenceManagement,
|
||||||
BilanCompetence
|
BilanCompetence
|
||||||
)
|
)
|
||||||
@ -95,7 +95,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationForm
|
model = RegistrationForm
|
||||||
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
||||||
|
|
||||||
def get_last_name(self, obj):
|
def get_last_name(self, obj):
|
||||||
return obj.student.last_name
|
return obj.student.last_name
|
||||||
|
|
||||||
@ -164,12 +164,20 @@ class StudentSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
if guardian_id:
|
if guardian_id:
|
||||||
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
||||||
guardian_instance, created = Guardian.objects.update_or_create(
|
try:
|
||||||
id=guardian_id,
|
guardian_instance = Guardian.objects.get(id=guardian_id)
|
||||||
defaults=guardian_data
|
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
|
||||||
)
|
for field, value in guardian_data.items():
|
||||||
guardians_ids.append(guardian_instance.id)
|
if field != 'id': # Ne pas mettre à jour l'ID
|
||||||
continue
|
setattr(guardian_instance, field, value)
|
||||||
|
guardian_instance.save()
|
||||||
|
guardians_ids.append(guardian_instance.id)
|
||||||
|
continue
|
||||||
|
except Guardian.DoesNotExist:
|
||||||
|
# Si le guardian n'existe pas, créer un nouveau
|
||||||
|
guardian_instance = Guardian.objects.create(**guardian_data)
|
||||||
|
guardians_ids.append(guardian_instance.id)
|
||||||
|
continue
|
||||||
|
|
||||||
if profile_role_data:
|
if profile_role_data:
|
||||||
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
||||||
|
|||||||
@ -5,6 +5,8 @@ from Subscriptions.automate import Automate_RF_Register, updateStateMachine
|
|||||||
from .models import RegistrationForm
|
from .models import RegistrationForm
|
||||||
from GestionMessagerie.models import Messagerie
|
from GestionMessagerie.models import Messagerie
|
||||||
from N3wtSchool import settings, bdd
|
from N3wtSchool import settings, bdd
|
||||||
|
from N3wtSchool.mailManager import sendMail, getConnection
|
||||||
|
from django.template.loader import render_to_string
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -26,17 +28,82 @@ def send_notification(dossier):
|
|||||||
# Changer l'état de l'automate
|
# Changer l'état de l'automate
|
||||||
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
||||||
|
|
||||||
url = settings.URL_DJANGO + 'GestionMessagerie/message'
|
# Envoyer un email de relance aux responsables
|
||||||
|
try:
|
||||||
|
# Récupérer l'établissement du dossier
|
||||||
|
establishment_id = dossier.establishment.id
|
||||||
|
|
||||||
destinataires = dossier.eleve.profiles.all()
|
# Obtenir la connexion SMTP pour cet établissement
|
||||||
for destinataire in destinataires:
|
connection = getConnection(establishment_id)
|
||||||
message = {
|
|
||||||
"objet": "[RELANCE]",
|
# Préparer le contenu de l'email
|
||||||
"destinataire" : destinataire.id,
|
subject = f"[RELANCE] Dossier d'inscription en attente - {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||||
"corpus": "RELANCE pour le dossier d'inscription"
|
|
||||||
|
context = {
|
||||||
|
'student_name': f"{dossier.eleve.first_name} {dossier.eleve.last_name}",
|
||||||
|
'deadline_date': (timezone.now() - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)).strftime('%d/%m/%Y'),
|
||||||
|
'establishment_name': dossier.establishment.name,
|
||||||
|
'base_url': settings.BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=message)
|
# Utiliser un template HTML pour l'email (si disponible)
|
||||||
|
try:
|
||||||
|
html_message = render_to_string('emails/relance_signature.html', context)
|
||||||
|
except:
|
||||||
|
# Si pas de template, message simple
|
||||||
|
html_message = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Relance - Dossier d'inscription en attente</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Le dossier d'inscription de <strong>{context['student_name']}</strong> est en attente de signature depuis plus de {settings.EXPIRATION_DI_NB_DAYS} jours.</p>
|
||||||
|
<p>Merci de vous connecter à votre espace pour finaliser l'inscription.</p>
|
||||||
|
<p>Cordialement,<br>L'équipe {context['establishment_name']}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Récupérer les emails des responsables
|
||||||
|
destinataires = []
|
||||||
|
profiles = dossier.eleve.profiles.all()
|
||||||
|
for profile in profiles:
|
||||||
|
if profile.email:
|
||||||
|
destinataires.append(profile.email)
|
||||||
|
|
||||||
|
if destinataires:
|
||||||
|
# Envoyer l'email
|
||||||
|
result = sendMail(
|
||||||
|
subject=subject,
|
||||||
|
message=html_message,
|
||||||
|
recipients=destinataires,
|
||||||
|
connection=connection
|
||||||
|
)
|
||||||
|
logger.info(f"Email de relance envoyé pour le dossier {dossier.id} à {destinataires}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Aucun email trouvé pour les responsables du dossier {dossier.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de l'envoi de l'email de relance pour le dossier {dossier.id}: {str(e)}")
|
||||||
|
|
||||||
|
# En cas d'erreur email, utiliser la messagerie interne comme fallback
|
||||||
|
try:
|
||||||
|
url = settings.URL_DJANGO + 'GestionMessagerie/send-message/'
|
||||||
|
|
||||||
|
# Créer ou récupérer une conversation avec chaque responsable
|
||||||
|
destinataires = dossier.eleve.profiles.all()
|
||||||
|
for destinataire in destinataires:
|
||||||
|
message_data = {
|
||||||
|
"conversation_id": None, # Sera géré par l'API
|
||||||
|
"sender_id": 1, # ID du système ou admin
|
||||||
|
"content": f"RELANCE pour le dossier d'inscription de {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=message_data)
|
||||||
|
if response.status_code != 201:
|
||||||
|
logger.error(f"Erreur lors de l'envoi du message interne: {response.text}")
|
||||||
|
|
||||||
|
except Exception as inner_e:
|
||||||
|
logger.error(f"Erreur lors de l'envoi du message interne de fallback: {str(inner_e)}")
|
||||||
|
|
||||||
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
||||||
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
||||||
|
|||||||
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Relance - Dossier d'inscription</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 3px solid #007bff;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #007bff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.alert-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.student-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 25px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{ establishment_name }}</h1>
|
||||||
|
<p>Relance - Dossier d'inscription</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
<strong>Attention :</strong> Votre dossier d'inscription nécessite votre attention
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<h3>Dossier d'inscription de : <strong>{{ student_name }}</strong></h3>
|
||||||
|
<p>En attente depuis le : <strong>{{ deadline_date }}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nous vous informons que le dossier d'inscription mentionné ci-dessus est en attente de finalisation depuis plus de {{ deadline_date }}.</p>
|
||||||
|
|
||||||
|
<p><strong>Action requise :</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Connectez-vous à votre espace personnel</li>
|
||||||
|
<li>Vérifiez les documents manquants</li>
|
||||||
|
<li>Complétez et signez les formulaires en attente</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{ base_url }}" class="cta-button">Accéder à mon espace</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si vous rencontrez des difficultés ou avez des questions concernant ce dossier, n'hésitez pas à nous contacter.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Cordialement,<br>
|
||||||
|
L'équipe {{ establishment_name }}</p>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0;">
|
||||||
|
|
||||||
|
<p style="font-size: 12px;">
|
||||||
|
Cet email a été envoyé automatiquement. Si vous pensez avoir reçu ce message par erreur,
|
||||||
|
veuillez contacter l'établissement directement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -24,7 +24,10 @@ 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 (
|
||||||
|
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"),
|
||||||
|
|||||||
@ -8,6 +8,9 @@ from N3wtSchool import renderers
|
|||||||
from N3wtSchool import bdd
|
from N3wtSchool import bdd
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
@ -21,8 +24,250 @@ 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)
|
||||||
|
"""
|
||||||
|
# Si c'est du JSON pur (Content-Type: application/json)
|
||||||
|
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
|
||||||
|
try:
|
||||||
|
# request.data contient déjà le JSON parsé par Django REST
|
||||||
|
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
|
||||||
|
logger.info(f"JSON payload extracted: {payload}")
|
||||||
|
return payload, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error processing JSON: {e}')
|
||||||
|
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Cas multipart/form-data avec champ 'data'
|
||||||
|
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
|
||||||
|
if data_field:
|
||||||
|
try:
|
||||||
|
# Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder
|
||||||
|
if hasattr(data_field, 'read'):
|
||||||
|
raw = data_field.read()
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
text = raw.decode('utf-8')
|
||||||
|
else:
|
||||||
|
text = raw
|
||||||
|
payload = json.loads(text)
|
||||||
|
# Si 'data' est bytes déjà
|
||||||
|
elif isinstance(data_field, (bytes, bytearray)):
|
||||||
|
payload = json.loads(data_field.decode('utf-8'))
|
||||||
|
# Si 'data' est une string JSON
|
||||||
|
elif isinstance(data_field, str):
|
||||||
|
payload = json.loads(data_field)
|
||||||
|
else:
|
||||||
|
# type inattendu
|
||||||
|
raise ValueError(f"Unsupported 'data' type: {type(data_field)}")
|
||||||
|
except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
|
||||||
|
logger.error(f'Invalid JSON in "data": {e}')
|
||||||
|
return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
else:
|
||||||
|
payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)
|
||||||
|
if isinstance(payload, QueryDict):
|
||||||
|
payload = payload.dict()
|
||||||
|
|
||||||
|
# Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité)
|
||||||
|
for f_key, f_val in request.FILES.items():
|
||||||
|
if f_key == 'data':
|
||||||
|
# remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr
|
||||||
|
try:
|
||||||
|
f_val.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# ne pas mettre le fichier 'data' dans le payload (c'est le JSON)
|
||||||
|
continue
|
||||||
|
payload[f_key] = f_val
|
||||||
|
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
def create_templates_for_registration_form(register_form):
|
||||||
|
"""
|
||||||
|
Idempotent:
|
||||||
|
- supprime les templates existants qui ne correspondent pas
|
||||||
|
aux masters du fileGroup courant du register_form (et supprime leurs fichiers).
|
||||||
|
- crée les templates manquants pour les masters du fileGroup courant.
|
||||||
|
Retourne la liste des templates créés.
|
||||||
|
"""
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationSchoolFileMaster,
|
||||||
|
RegistrationSchoolFileTemplate,
|
||||||
|
RegistrationParentFileMaster,
|
||||||
|
RegistrationParentFileTemplate,
|
||||||
|
registration_school_file_upload_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
created = []
|
||||||
|
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
|
||||||
|
|
||||||
|
# Récupérer les masters du fileGroup courant
|
||||||
|
current_group = getattr(register_form, "fileGroup", None)
|
||||||
|
if not current_group:
|
||||||
|
# Si plus de fileGroup, supprimer tous les templates existants pour ce RF
|
||||||
|
school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form)
|
||||||
|
for t in school_existing:
|
||||||
|
try:
|
||||||
|
if getattr(t, "file", None):
|
||||||
|
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
|
||||||
|
t.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
|
||||||
|
t.delete()
|
||||||
|
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
|
||||||
|
for t in parent_existing:
|
||||||
|
try:
|
||||||
|
if getattr(t, "file", None):
|
||||||
|
t.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
|
||||||
|
t.delete()
|
||||||
|
return created
|
||||||
|
|
||||||
|
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
|
||||||
|
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
|
||||||
|
|
||||||
|
school_master_ids = {m.pk for m in school_masters}
|
||||||
|
parent_master_ids = {m.pk for m in parent_masters}
|
||||||
|
|
||||||
|
# Supprimer les school templates obsolètes
|
||||||
|
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
|
||||||
|
if not tmpl.master_id or tmpl.master_id not in school_master_ids:
|
||||||
|
try:
|
||||||
|
if getattr(tmpl, "file", None):
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None))
|
||||||
|
tmpl.delete()
|
||||||
|
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||||
|
|
||||||
|
# Supprimer les parent templates obsolètes
|
||||||
|
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
|
||||||
|
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
|
||||||
|
try:
|
||||||
|
if getattr(tmpl, "file", None):
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
|
||||||
|
tmpl.delete()
|
||||||
|
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||||
|
|
||||||
|
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
|
||||||
|
for m in school_masters:
|
||||||
|
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
|
||||||
|
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
|
||||||
|
|
||||||
|
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||||
|
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||||
|
|
||||||
|
file_name = None
|
||||||
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
|
file_name = os.path.basename(m.file.name)
|
||||||
|
elif m.file:
|
||||||
|
file_name = str(m.file)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
|
||||||
|
file_name = os.path.basename(pdf_file.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||||
|
file_name = None
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
upload_rel_path = registration_school_file_upload_to(
|
||||||
|
type("Tmp", (), {
|
||||||
|
"registration_form": register_form,
|
||||||
|
"establishment": getattr(register_form, "establishment", None),
|
||||||
|
"student": getattr(register_form, "student", None)
|
||||||
|
})(),
|
||||||
|
file_name
|
||||||
|
)
|
||||||
|
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
|
||||||
|
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||||
|
|
||||||
|
if tmpl:
|
||||||
|
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
||||||
|
master_file_changed = template_file_name != file_name
|
||||||
|
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||||
|
if master_file_changed or (
|
||||||
|
master_file_path and os.path.exists(master_file_path) and
|
||||||
|
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
|
||||||
|
):
|
||||||
|
# Supprimer l'ancien fichier du template (même si le nom change)
|
||||||
|
if tmpl.file and tmpl.file.name:
|
||||||
|
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
|
||||||
|
if os.path.exists(old_template_path):
|
||||||
|
try:
|
||||||
|
os.remove(old_template_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur suppression ancien fichier template: {e}")
|
||||||
|
# Copier le nouveau fichier du master (form existant)
|
||||||
|
if master_file_path and os.path.exists(master_file_path):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(master_file_path, abs_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||||
|
tmpl.file.name = upload_rel_path
|
||||||
|
tmpl.name = m.name or ""
|
||||||
|
tmpl.slug = slug
|
||||||
|
tmpl.formTemplateData = m.formMasterData or []
|
||||||
|
tmpl.save()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sinon, création du template comme avant
|
||||||
|
tmpl = RegistrationSchoolFileTemplate(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
name=m.name or "",
|
||||||
|
formTemplateData=m.formMasterData or [],
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
if file_name:
|
||||||
|
# Copier le fichier du master si besoin (form existant)
|
||||||
|
if master_file_path and not os.path.exists(abs_path):
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
shutil.copy2(master_file_path, abs_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
||||||
|
tmpl.file.name = upload_rel_path
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
|
||||||
|
# Créer les parent templates manquants
|
||||||
|
for m in parent_masters:
|
||||||
|
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
tmpl = RegistrationParentFileTemplate.objects.create(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
file=None,
|
||||||
|
)
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
def recupereListeFichesInscription():
|
def recupereListeFichesInscription():
|
||||||
"""
|
"""
|
||||||
Retourne la liste complète des fiches d’inscription.
|
Retourne la liste complète des fiches d’inscription.
|
||||||
@ -212,4 +457,52 @@ def getHistoricalYears(count=5):
|
|||||||
historical_start_year = start_year - i
|
historical_start_year = start_year - i
|
||||||
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
||||||
|
|
||||||
return historical_years
|
return historical_years
|
||||||
|
|
||||||
|
def generate_form_json_pdf(register_form, form_json):
|
||||||
|
"""
|
||||||
|
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
||||||
|
et l'associe au RegistrationSchoolFileTemplate.
|
||||||
|
Le PDF contient le titre, les labels et types de champs.
|
||||||
|
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Récupérer le nom du formulaire
|
||||||
|
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||||
|
filename = f"{form_name}.pdf"
|
||||||
|
|
||||||
|
# Générer le PDF
|
||||||
|
buffer = BytesIO()
|
||||||
|
c = canvas.Canvas(buffer, pagesize=A4)
|
||||||
|
y = 800
|
||||||
|
|
||||||
|
# Titre
|
||||||
|
c.setFont("Helvetica-Bold", 20)
|
||||||
|
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||||
|
y -= 40
|
||||||
|
|
||||||
|
# Champs
|
||||||
|
c.setFont("Helvetica", 12)
|
||||||
|
fields = form_json.get("fields", [])
|
||||||
|
for field in fields:
|
||||||
|
label = field.get("label", field.get("id", ""))
|
||||||
|
ftype = field.get("type", "")
|
||||||
|
c.drawString(100, y, f"{label} [{ftype}]")
|
||||||
|
y -= 25
|
||||||
|
if y < 100:
|
||||||
|
c.showPage()
|
||||||
|
y = 800
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
buffer.seek(0)
|
||||||
|
pdf_content = buffer.read()
|
||||||
|
|
||||||
|
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||||
|
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||||
|
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||||
|
if os.path.exists(existing_file_path):
|
||||||
|
os.remove(existing_file_path)
|
||||||
|
register_form.registration_file.delete(save=False)
|
||||||
|
|
||||||
|
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||||
|
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||||
|
|||||||
@ -1,11 +1,25 @@
|
|||||||
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
from .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,
|
||||||
|
)
|
||||||
|
from .registration_school_file_templates_views import (
|
||||||
RegistrationSchoolFileTemplateView,
|
RegistrationSchoolFileTemplateView,
|
||||||
RegistrationSchoolFileTemplateSimpleView,
|
RegistrationSchoolFileTemplateSimpleView
|
||||||
|
)
|
||||||
|
from .registration_parent_file_masters_views import (
|
||||||
RegistrationParentFileMasterView,
|
RegistrationParentFileMasterView,
|
||||||
RegistrationParentFileMasterSimpleView,
|
RegistrationParentFileMasterSimpleView
|
||||||
|
)
|
||||||
|
from .registration_parent_file_templates_views import (
|
||||||
RegistrationParentFileTemplateSimpleView,
|
RegistrationParentFileTemplateSimpleView,
|
||||||
RegistrationParentFileTemplateView
|
RegistrationParentFileTemplateView
|
||||||
)
|
)
|
||||||
@ -33,7 +47,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',
|
||||||
|
|||||||
@ -17,10 +17,10 @@ import Subscriptions.util as util
|
|||||||
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
||||||
from Subscriptions.pagination import CustomSubscriptionPagination
|
from Subscriptions.pagination import CustomSubscriptionPagination
|
||||||
from Subscriptions.models import (
|
from Subscriptions.models import (
|
||||||
Guardian,
|
Guardian,
|
||||||
RegistrationForm,
|
RegistrationForm,
|
||||||
RegistrationSchoolFileTemplate,
|
RegistrationSchoolFileTemplate,
|
||||||
RegistrationFileGroup,
|
RegistrationFileGroup,
|
||||||
RegistrationParentFileTemplate,
|
RegistrationParentFileTemplate,
|
||||||
StudentCompetency
|
StudentCompetency
|
||||||
)
|
)
|
||||||
@ -431,6 +431,262 @@ class RegisterFormWithIdView(APIView):
|
|||||||
# Retourner les données mises à jour
|
# Retourner les données mises à jour
|
||||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
request_body=openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
|
||||||
|
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
|
||||||
|
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
|
||||||
|
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
|
||||||
|
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
|
||||||
|
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
responses={200: RegistrationFormSerializer()},
|
||||||
|
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
|
||||||
|
operation_summary="Auto-sauvegarder un dossier d'inscription"
|
||||||
|
)
|
||||||
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
|
def patch(self, request, id):
|
||||||
|
"""
|
||||||
|
Auto-sauvegarde partielle d'un dossier d'inscription.
|
||||||
|
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Récupérer le dossier d'inscription
|
||||||
|
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
|
||||||
|
if not registerForm:
|
||||||
|
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Préparer les données à mettre à jour
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Traiter les données étudiant si présentes
|
||||||
|
if 'student_data' in request.data:
|
||||||
|
try:
|
||||||
|
student_data = json.loads(request.data['student_data'])
|
||||||
|
|
||||||
|
# Extraire les données de paiement des données étudiant
|
||||||
|
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
|
||||||
|
payment_data = {}
|
||||||
|
|
||||||
|
for field in payment_fields:
|
||||||
|
if field in student_data:
|
||||||
|
payment_data[field] = student_data.pop(field)
|
||||||
|
|
||||||
|
# Si nous avons des données de paiement, les traiter
|
||||||
|
if payment_data:
|
||||||
|
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
|
||||||
|
|
||||||
|
# Traiter les données de paiement
|
||||||
|
payment_updates = {}
|
||||||
|
|
||||||
|
# Gestion du mode de paiement d'inscription
|
||||||
|
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||||
|
registerForm.registration_payment = payment_mode
|
||||||
|
payment_updates['registration_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du mode de paiement de scolarité
|
||||||
|
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||||
|
registerForm.tuition_payment = payment_mode
|
||||||
|
payment_updates['tuition_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement d'inscription
|
||||||
|
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||||
|
registerForm.registration_payment_plan = payment_plan
|
||||||
|
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement de scolarité
|
||||||
|
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||||
|
registerForm.tuition_payment_plan = payment_plan
|
||||||
|
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Sauvegarder les modifications de paiement
|
||||||
|
if payment_updates:
|
||||||
|
registerForm.save()
|
||||||
|
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||||
|
|
||||||
|
update_data['student'] = student_data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in student_data")
|
||||||
|
|
||||||
|
# Traiter les données des responsables si présentes
|
||||||
|
if 'guardians_data' in request.data:
|
||||||
|
try:
|
||||||
|
guardians_data = json.loads(request.data['guardians_data'])
|
||||||
|
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
|
||||||
|
|
||||||
|
# Enregistrer directement chaque guardian avec le modèle
|
||||||
|
for i, guardian_data in enumerate(guardians_data):
|
||||||
|
guardian_id = guardian_data.get('id')
|
||||||
|
if guardian_id:
|
||||||
|
try:
|
||||||
|
# Récupérer le guardian existant et mettre à jour ses champs
|
||||||
|
guardian = Guardian.objects.get(id=guardian_id)
|
||||||
|
|
||||||
|
# Mettre à jour les champs si ils sont présents
|
||||||
|
if 'birth_date' in guardian_data and guardian_data['birth_date']:
|
||||||
|
guardian.birth_date = guardian_data['birth_date']
|
||||||
|
if 'profession' in guardian_data:
|
||||||
|
guardian.profession = guardian_data['profession']
|
||||||
|
if 'address' in guardian_data:
|
||||||
|
guardian.address = guardian_data['address']
|
||||||
|
if 'phone' in guardian_data:
|
||||||
|
guardian.phone = guardian_data['phone']
|
||||||
|
if 'first_name' in guardian_data:
|
||||||
|
guardian.first_name = guardian_data['first_name']
|
||||||
|
if 'last_name' in guardian_data:
|
||||||
|
guardian.last_name = guardian_data['last_name']
|
||||||
|
|
||||||
|
guardian.save()
|
||||||
|
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
|
||||||
|
|
||||||
|
except Guardian.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in guardians_data")
|
||||||
|
|
||||||
|
# Traiter les données de la fratrie si présentes
|
||||||
|
if 'siblings_data' in request.data:
|
||||||
|
try:
|
||||||
|
siblings_data = json.loads(request.data['siblings_data'])
|
||||||
|
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
|
||||||
|
|
||||||
|
# Enregistrer directement chaque sibling avec le modèle
|
||||||
|
for i, sibling_data in enumerate(siblings_data):
|
||||||
|
sibling_id = sibling_data.get('id')
|
||||||
|
if sibling_id:
|
||||||
|
try:
|
||||||
|
# Récupérer le sibling existant et mettre à jour ses champs
|
||||||
|
from Subscriptions.models import Sibling
|
||||||
|
sibling = Sibling.objects.get(id=sibling_id)
|
||||||
|
|
||||||
|
# Mettre à jour les champs si ils sont présents
|
||||||
|
if 'first_name' in sibling_data:
|
||||||
|
sibling.first_name = sibling_data['first_name']
|
||||||
|
if 'last_name' in sibling_data:
|
||||||
|
sibling.last_name = sibling_data['last_name']
|
||||||
|
if 'birth_date' in sibling_data and sibling_data['birth_date']:
|
||||||
|
sibling.birth_date = sibling_data['birth_date']
|
||||||
|
|
||||||
|
sibling.save()
|
||||||
|
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
|
||||||
|
|
||||||
|
except Sibling.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in siblings_data")
|
||||||
|
|
||||||
|
# Traiter les données de paiement si présentes
|
||||||
|
if 'payment_data' in request.data:
|
||||||
|
try:
|
||||||
|
payment_data = json.loads(request.data['payment_data'])
|
||||||
|
logger.debug(f"Auto-save: payment_data = {payment_data}")
|
||||||
|
|
||||||
|
# Mettre à jour directement les champs de paiement du formulaire
|
||||||
|
payment_updates = {}
|
||||||
|
|
||||||
|
# Gestion du mode de paiement d'inscription
|
||||||
|
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||||
|
registerForm.registration_payment = payment_mode
|
||||||
|
payment_updates['registration_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du mode de paiement de scolarité
|
||||||
|
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||||
|
registerForm.tuition_payment = payment_mode
|
||||||
|
payment_updates['tuition_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement d'inscription
|
||||||
|
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||||
|
registerForm.registration_payment_plan = payment_plan
|
||||||
|
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement de scolarité
|
||||||
|
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||||
|
registerForm.tuition_payment_plan = payment_plan
|
||||||
|
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Sauvegarder les modifications de paiement
|
||||||
|
if payment_updates:
|
||||||
|
registerForm.save()
|
||||||
|
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in payment_data")
|
||||||
|
|
||||||
|
# Mettre à jour la page actuelle si présente
|
||||||
|
if 'current_page' in request.data:
|
||||||
|
try:
|
||||||
|
current_page = int(request.data['current_page'])
|
||||||
|
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
|
||||||
|
logger.debug(f"Auto-save: current_page = {current_page}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Auto-save: Invalid current_page value")
|
||||||
|
|
||||||
|
# Effectuer la mise à jour partielle seulement si nous avons des données
|
||||||
|
if update_data:
|
||||||
|
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
logger.debug(f"Auto-save successful for student {id}")
|
||||||
|
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Auto-save validation errors: {serializer.errors}")
|
||||||
|
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
|
||||||
|
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
|
||||||
|
else:
|
||||||
|
# Pas de données à sauvegarder, mais on retourne un succès
|
||||||
|
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto-save error for student {id}: {str(e)}")
|
||||||
|
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
|
||||||
|
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
responses={204: 'No Content'},
|
responses={204: 'No Content'},
|
||||||
operation_description="Supprime un dossier d'inscription donné.",
|
operation_description="Supprime un dossier d'inscription donné.",
|
||||||
|
|||||||
@ -0,0 +1,272 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationForm,
|
||||||
|
RegistrationParentFileMaster,
|
||||||
|
RegistrationParentFileTemplate
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationParentFileMasterView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les fichiers parents liés à l'établissement
|
||||||
|
templates = RegistrationParentFileMaster.objects.filter(
|
||||||
|
groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau fichier parent",
|
||||||
|
request_body=RegistrationParentFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationParentFileMasterSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
logger.info(f"raw request.data: {request.data}")
|
||||||
|
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
logger.info(f"payload for serializer: {payload}")
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||||
|
try:
|
||||||
|
groups_qs = obj.groups.all()
|
||||||
|
if groups_qs.exists():
|
||||||
|
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||||
|
for rf in rfs:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(RegistrationParentFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationParentFileMasterSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un fichier parent spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileMasterSerializer,
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un fichier parent existant",
|
||||||
|
request_body=RegistrationParentFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileMasterSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# snapshot des groups avant update
|
||||||
|
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# groups après update
|
||||||
|
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
removed_group_ids = old_group_ids - new_group_ids
|
||||||
|
added_group_ids = new_group_ids - old_group_ids
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||||
|
if removed_group_ids:
|
||||||
|
try:
|
||||||
|
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||||
|
for rf in rfs_removed:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||||
|
if added_group_ids:
|
||||||
|
try:
|
||||||
|
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||||
|
for rf in rfs_added:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un fichier parent",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||||
|
templates = RegistrationParentFileTemplate.objects.filter(
|
||||||
|
master__groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau template d'inscription",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un template d'inscription existant",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé avant suppression de l'objet
|
||||||
|
if template.file and template.file.name:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationParentFileTemplateSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationParentFileTemplate
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||||
|
templates = RegistrationParentFileTemplate.objects.filter(
|
||||||
|
master__groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau template d'inscription",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un template d'inscription existant",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé
|
||||||
|
if template.file and template.file.name:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationForm,
|
||||||
|
RegistrationSchoolFileMaster,
|
||||||
|
RegistrationSchoolFileTemplate
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileMasterView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||||
|
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||||
|
groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau master de template d'inscription",
|
||||||
|
request_body=RegistrationSchoolFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationSchoolFileMasterSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
logger.info(f"raw request.data: {request.data}")
|
||||||
|
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"payload for serializer: {payload}")
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||||
|
try:
|
||||||
|
groups_qs = obj.groups.all()
|
||||||
|
if groups_qs.exists():
|
||||||
|
# Tous les RegistrationForm dont fileGroup est dans les groups du master
|
||||||
|
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||||
|
for rf in rfs:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s from master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while propagating templates after master creation %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
|
||||||
|
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un master de template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileMasterSerializer,
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(master)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un master de template d'inscription existant",
|
||||||
|
request_body=RegistrationSchoolFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileMasterSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# snapshot des groups avant update
|
||||||
|
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# groups après update
|
||||||
|
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
removed_group_ids = old_group_ids - new_group_ids
|
||||||
|
added_group_ids = new_group_ids - old_group_ids
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||||
|
if removed_group_ids:
|
||||||
|
logger.info("REMOVE IDs")
|
||||||
|
try:
|
||||||
|
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||||
|
for rf in rfs_removed:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf) # supprimera les templates obsolètes
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error cleaning templates for RF %s after master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for removed groups after master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||||
|
if added_group_ids:
|
||||||
|
logger.info("ADD IDs")
|
||||||
|
try:
|
||||||
|
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||||
|
for rf in rfs_added:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf) # créera les templates manquants
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s after master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for added groups after master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un master de template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is not None:
|
||||||
|
# Supprimer tous les templates liés et leurs fichiers PDF
|
||||||
|
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
|
||||||
|
for template in templates:
|
||||||
|
if template.file and template.file.name:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
template.delete()
|
||||||
|
master.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -5,12 +5,18 @@ 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 os
|
||||||
|
|
||||||
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 +51,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 +93,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 +119,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)
|
||||||
|
|
||||||
@ -185,6 +208,17 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
def delete(self, request, id):
|
def delete(self, request, id):
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
if template is not None:
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé
|
||||||
|
if template.file and template.file.name:
|
||||||
|
file_path = template.file.path
|
||||||
|
template.file.delete(save=False)
|
||||||
|
# Vérification post-suppression
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||||
template.delete()
|
template.delete()
|
||||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
else:
|
else:
|
||||||
@ -366,6 +400,17 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
|||||||
def delete(self, request, id):
|
def delete(self, request, id):
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
if template is not None:
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé
|
||||||
|
if template.file and template.file.name:
|
||||||
|
file_path = template.file.path
|
||||||
|
template.file.delete(save=False)
|
||||||
|
# Vérification post-suppression
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||||
template.delete()
|
template.delete()
|
||||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
else:
|
else:
|
||||||
Binary file not shown.
@ -12,21 +12,32 @@ 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'
|
||||||
|
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
|
||||||
|
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
collect_static_cmd = [
|
||||||
|
["python", "manage.py", "collectstatic", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
|
flush_data_cmd = [
|
||||||
|
["python", "manage.py", "flush", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
|
migrate_commands = [
|
||||||
|
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "School", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"],
|
|
||||||
#["python", "manage.py", "flush", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "School", "--noinput"],
|
|
||||||
["python", "manage.py", "migrate", "--noinput"]
|
["python", "manage.py", "migrate", "--noinput"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -45,14 +56,29 @@ def run_daphne():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
for command in collect_static_cmd:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if flush_data:
|
||||||
|
for command in flush_data_cmd:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if migrate_data:
|
||||||
|
for command in migrate_commands:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
for command in commands:
|
for command in commands:
|
||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
#if test_mode:
|
if test_mode:
|
||||||
# for test_command in test_commands:
|
for test_command in test_commands:
|
||||||
# if run_command(test_command) != 0:
|
if run_command(test_command) != 0:
|
||||||
# exit(1)
|
exit(1)
|
||||||
|
|
||||||
if watch_mode:
|
if watch_mode:
|
||||||
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
|
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
37
Front-End/package-lock.json
generated
37
Front-End/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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,20 +34,21 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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';
|
||||||
@ -34,10 +34,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
fetchRegistrationSchoolFileMasters,
|
fetchRegistrationSchoolFileMasters,
|
||||||
fetchRegistrationParentFileMasters,
|
fetchRegistrationParentFileMasters
|
||||||
cloneTemplate,
|
|
||||||
createRegistrationSchoolFileTemplate,
|
|
||||||
createRegistrationParentFileTemplate,
|
|
||||||
} from '@/app/actions/registerFileGroupAction';
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
import { fetchProfiles } from '@/app/actions/authAction';
|
import { fetchProfiles } from '@/app/actions/authAction';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -96,7 +93,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 +519,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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
@ -104,12 +100,12 @@ export async function createRegistrationFileGroup(groupData, csrfToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
|
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
|
||||||
|
// Toujours FormData, jamais JSON
|
||||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: data,
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
@ -190,10 +186,9 @@ export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
|
|||||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: data,
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRFToken': csrfToken,
|
'X-CSRFToken': csrfToken,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
@ -327,62 +322,3 @@ export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// API requests
|
|
||||||
export const removeTemplate = (templateId, selectedEstablishmentId, apiDocuseal) => {
|
|
||||||
return fetch(`${FE_API_DOCUSEAL_DELETE_URL}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
templateId,
|
|
||||||
establishment_id :selectedEstablishmentId,
|
|
||||||
apiDocuseal
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(requestResponseHandler)
|
|
||||||
.catch(errorHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cloneTemplate = (templateId, email, is_required, selectedEstablishmentId, apiDocuseal) => {
|
|
||||||
return fetch(`${FE_API_DOCUSEAL_CLONE_URL}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
templateId,
|
|
||||||
email,
|
|
||||||
is_required,
|
|
||||||
establishment_id :selectedEstablishmentId,
|
|
||||||
apiDocuseal
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(requestResponseHandler)
|
|
||||||
.catch(errorHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) => {
|
|
||||||
const url = `${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}?establishment_id=${selectedEstablishmentId}&apiDocuseal=${apiDocuseal}`;
|
|
||||||
return fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(requestResponseHandler)
|
|
||||||
.catch(errorHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateToken = (email, id = null, selectedEstablishmentId, apiDocuseal) => {
|
|
||||||
return fetch(`${FE_API_DOCUSEAL_GENERATE_TOKEN}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ user_email: email, id, establishment_id :selectedEstablishmentId, apiDocuseal }),
|
|
||||||
})
|
|
||||||
.then(requestResponseHandler)
|
|
||||||
.catch(errorHandler);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import {
|
|||||||
BE_SUBSCRIPTION_ABSENCES_URL,
|
BE_SUBSCRIPTION_ABSENCES_URL,
|
||||||
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
|
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
|
||||||
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
|
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
|
||||||
|
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
|
||||||
|
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
|
||||||
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
||||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
export const editStudentCompetencies = (data, csrfToken) => {
|
export const editStudentCompetencies = (data, csrfToken) => {
|
||||||
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
||||||
@ -83,6 +86,45 @@ export const editRegisterForm = (id, data, csrfToken) => {
|
|||||||
.catch(errorHandler);
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||||
|
try {
|
||||||
|
// Version allégée pour auto-save - ne pas envoyer tous les fichiers
|
||||||
|
const autoSaveData = new FormData();
|
||||||
|
|
||||||
|
// Ajouter seulement les données textuelles pour l'auto-save
|
||||||
|
if (data.student) {
|
||||||
|
autoSaveData.append('student_data', JSON.stringify(data.student));
|
||||||
|
}
|
||||||
|
if (data.guardians) {
|
||||||
|
autoSaveData.append('guardians_data', JSON.stringify(data.guardians));
|
||||||
|
}
|
||||||
|
if (data.siblings) {
|
||||||
|
autoSaveData.append('siblings_data', JSON.stringify(data.siblings));
|
||||||
|
}
|
||||||
|
if (data.currentPage) {
|
||||||
|
autoSaveData.append('current_page', data.currentPage);
|
||||||
|
}
|
||||||
|
autoSaveData.append('auto_save', 'true');
|
||||||
|
|
||||||
|
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||||
|
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: autoSaveData,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(() => {
|
||||||
|
// Silent fail pour l'auto-save
|
||||||
|
logger.debug('Auto-save failed silently');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail pour l'auto-save
|
||||||
|
logger.debug('Auto-save error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createRegisterForm = (data, csrfToken) => {
|
export const createRegisterForm = (data, csrfToken) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
|
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
@ -302,3 +344,68 @@ export const deleteAbsences = (id, csrfToken) => {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les formulaires maîtres d'inscription pour un établissement
|
||||||
|
* @param {number} establishmentId - ID de l'établissement
|
||||||
|
* @returns {Promise<Array>} Liste des formulaires
|
||||||
|
*/
|
||||||
|
export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
|
||||||
|
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde les réponses d'un formulaire dans RegistrationSchoolFileTemplate
|
||||||
|
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||||
|
* @param {Object} formTemplateData - Données du formulaire à sauvegarder
|
||||||
|
* @param {string} csrfToken - Token CSRF
|
||||||
|
* @returns {Promise} Résultat de la sauvegarde
|
||||||
|
*/
|
||||||
|
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
|
||||||
|
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
formTemplateData: formTemplateData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les données sauvegardées d'un RegistrationSchoolFileTemplate
|
||||||
|
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||||
|
* @returns {Promise<Object>} Template avec formTemplateData
|
||||||
|
*/
|
||||||
|
export const fetchFormResponses = (templateId) => {
|
||||||
|
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
|
};
|
||||||
|
|||||||
@ -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([]);
|
||||||
|
|||||||
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composant indicateur de sauvegarde automatique
|
||||||
|
* @param {Boolean} isSaving - Si la sauvegarde est en cours
|
||||||
|
* @param {Date} lastSaved - Date de la dernière sauvegarde
|
||||||
|
* @param {Boolean} autoSaveEnabled - Si l'auto-save est activée
|
||||||
|
* @param {Function} onToggleAutoSave - Callback pour activer/désactiver l'auto-save
|
||||||
|
*/
|
||||||
|
export default function AutoSaveIndicator({
|
||||||
|
isSaving = false,
|
||||||
|
lastSaved = null,
|
||||||
|
autoSaveEnabled = true,
|
||||||
|
onToggleAutoSave = null,
|
||||||
|
}) {
|
||||||
|
if (!autoSaveEnabled && !lastSaved && !isSaving) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="text-sm text-blue-600 font-medium">
|
||||||
|
Sauvegarde en cours...
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : lastSaved ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-600">
|
||||||
|
Sauvegardé à {lastSaved.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">Auto-sauvegarde activée</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onToggleAutoSave && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleAutoSave}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
|
||||||
|
autoSaveEnabled
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
autoSaveEnabled
|
||||||
|
? 'Désactiver la sauvegarde automatique'
|
||||||
|
: 'Activer la sauvegarde automatique'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{autoSaveEnabled ? '✓ Auto-save' : '○ Auto-save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
714
Front-End/src/components/Form/AddFieldModal.js
Normal file
714
Front-End/src/components/Form/AddFieldModal.js
Normal file
@ -0,0 +1,714 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import InputTextIcon from './InputTextIcon';
|
||||||
|
import SelectChoice from './SelectChoice';
|
||||||
|
import Button from './Button';
|
||||||
|
import IconSelector from './IconSelector';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import { FIELD_TYPES } from './FormTypes';
|
||||||
|
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
|
||||||
|
|
||||||
|
export default function AddFieldModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
editingField = null,
|
||||||
|
editingIndex = -1,
|
||||||
|
}) {
|
||||||
|
const isEditing = editingIndex >= 0;
|
||||||
|
|
||||||
|
const [currentField, setCurrentField] = useState({
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
icon: '',
|
||||||
|
options: [],
|
||||||
|
text: '',
|
||||||
|
placeholder: '',
|
||||||
|
acceptTypes: '',
|
||||||
|
maxSize: 5, // 5MB par défaut
|
||||||
|
checked: false,
|
||||||
|
validation: {
|
||||||
|
pattern: '',
|
||||||
|
minLength: '',
|
||||||
|
maxLength: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||||
|
const [newOption, setNewOption] = useState('');
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset, setValue } = useForm();
|
||||||
|
|
||||||
|
// Mettre à jour l'état et les valeurs du formulaire lorsque editingField change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const defaultValues = editingField || {
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
icon: '',
|
||||||
|
options: [],
|
||||||
|
text: '',
|
||||||
|
placeholder: '',
|
||||||
|
acceptTypes: '',
|
||||||
|
maxSize: 5,
|
||||||
|
checked: false,
|
||||||
|
signatureData: '',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
penColor: '#000000',
|
||||||
|
penWidth: 2,
|
||||||
|
validation: {
|
||||||
|
pattern: '',
|
||||||
|
minLength: '',
|
||||||
|
maxLength: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si un type a été présélectionné depuis le sélecteur de type
|
||||||
|
if (editingField && !isEditing) {
|
||||||
|
// S'assurer que le type est correctement défini
|
||||||
|
if (typeof editingField.type === 'string') {
|
||||||
|
defaultValues.type = editingField.type;
|
||||||
|
} else if (editingField.value) {
|
||||||
|
defaultValues.type = editingField.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentField(defaultValues);
|
||||||
|
|
||||||
|
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
|
||||||
|
reset({
|
||||||
|
type: defaultValues.type,
|
||||||
|
label: defaultValues.label,
|
||||||
|
placeholder: defaultValues.placeholder,
|
||||||
|
required: defaultValues.required,
|
||||||
|
icon: defaultValues.icon,
|
||||||
|
text: defaultValues.text,
|
||||||
|
acceptTypes: defaultValues.acceptTypes,
|
||||||
|
maxSize: defaultValues.maxSize,
|
||||||
|
checked: defaultValues.checked,
|
||||||
|
signatureData: defaultValues.signatureData,
|
||||||
|
backgroundColor: defaultValues.backgroundColor,
|
||||||
|
penColor: defaultValues.penColor,
|
||||||
|
penWidth: defaultValues.penWidth,
|
||||||
|
validation: defaultValues.validation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, editingField, reset, isEditing]);
|
||||||
|
|
||||||
|
// Ajouter une option au select
|
||||||
|
const addOption = (e) => {
|
||||||
|
// Arrêter la propagation de l'événement pour éviter que le clic n'atteigne l'arrière-plan
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOption.trim()) {
|
||||||
|
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||||
|
const currentOptions = Array.isArray(currentField.options)
|
||||||
|
? currentField.options
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
options: [...currentOptions, newOption.trim()],
|
||||||
|
});
|
||||||
|
setNewOption('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer une option du select
|
||||||
|
const removeOption = (index) => {
|
||||||
|
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||||
|
const currentOptions = Array.isArray(currentField.options)
|
||||||
|
? currentField.options
|
||||||
|
: [];
|
||||||
|
const newOptions = currentOptions.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
setCurrentField({ ...currentField, options: newOptions });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sélectionner une icône
|
||||||
|
const selectIcon = (iconName) => {
|
||||||
|
setCurrentField({ ...currentField, icon: iconName });
|
||||||
|
|
||||||
|
// Mettre à jour la valeur dans le formulaire
|
||||||
|
const iconField = control._fields.icon;
|
||||||
|
if (iconField && iconField.onChange) {
|
||||||
|
iconField.onChange(iconName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldSubmit = (data) => {
|
||||||
|
onSubmit(data, currentField, editingIndex);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-semibold">
|
||||||
|
{isEditing ? 'Modifier le champ' : 'Ajouter un champ'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(handleFieldSubmit)} className="space-y-4">
|
||||||
|
<Controller
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.type}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<SelectChoice
|
||||||
|
label="Type de champ"
|
||||||
|
name="type"
|
||||||
|
selected={value}
|
||||||
|
callback={(e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
onChange(newType);
|
||||||
|
|
||||||
|
// Assurons-nous que les options restent un tableau si on sélectionne select ou radio
|
||||||
|
let updatedOptions = currentField.options;
|
||||||
|
|
||||||
|
// Si options n'existe pas ou n'est pas un tableau, initialiser comme tableau vide
|
||||||
|
if (!updatedOptions || !Array.isArray(updatedOptions)) {
|
||||||
|
updatedOptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
type: newType,
|
||||||
|
options: updatedOptions,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
choices={FIELD_TYPES_WITH_ICONS}
|
||||||
|
placeHolder="Sélectionner un type"
|
||||||
|
required
|
||||||
|
showIcons={true}
|
||||||
|
customSelect={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{![
|
||||||
|
'paragraph',
|
||||||
|
'heading1',
|
||||||
|
'heading2',
|
||||||
|
'heading3',
|
||||||
|
'heading4',
|
||||||
|
'heading5',
|
||||||
|
'heading6',
|
||||||
|
].includes(currentField.type) && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="label"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.label}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Label du champ"
|
||||||
|
name="label"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
label: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="placeholder"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.placeholder}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Placeholder (optionnel)"
|
||||||
|
name="placeholder"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
placeholder: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Controller
|
||||||
|
name="required"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.required}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="required"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.checked);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
required: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="required">Champ obligatoire</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(currentField.type === 'text' ||
|
||||||
|
currentField.type === 'email' ||
|
||||||
|
currentField.type === 'date') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Icône (optionnel)
|
||||||
|
</label>
|
||||||
|
<Controller
|
||||||
|
name="icon"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.icon}
|
||||||
|
render={({ field: { onChange } }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 flex items-center gap-2 p-3 border border-gray-300 rounded-md bg-gray-50">
|
||||||
|
{currentField.icon &&
|
||||||
|
LucideIcons[currentField.icon] ? (
|
||||||
|
<>
|
||||||
|
{React.createElement(
|
||||||
|
LucideIcons[currentField.icon],
|
||||||
|
{
|
||||||
|
size: 20,
|
||||||
|
className: 'text-gray-600',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{currentField.icon}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Aucune icône sélectionnée
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
text="Choisir"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowIconPicker(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||||
|
/>
|
||||||
|
{currentField.icon && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
text="✕"
|
||||||
|
onClick={() => {
|
||||||
|
onChange('');
|
||||||
|
setCurrentField({ ...currentField, icon: '' });
|
||||||
|
}}
|
||||||
|
className="px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{[
|
||||||
|
'paragraph',
|
||||||
|
'heading1',
|
||||||
|
'heading2',
|
||||||
|
'heading3',
|
||||||
|
'heading4',
|
||||||
|
'heading5',
|
||||||
|
'heading6',
|
||||||
|
].includes(currentField.type) && (
|
||||||
|
<Controller
|
||||||
|
name="text"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.text}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{currentField.type === 'paragraph'
|
||||||
|
? 'Texte du paragraphe'
|
||||||
|
: 'Texte du titre'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
text: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'select' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Options de la liste
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newOption}
|
||||||
|
onChange={(e) => setNewOption(e.target.value)}
|
||||||
|
placeholder="Nouvelle option"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === 'Enter' && (e.preventDefault(), addOption())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
text="Ajouter"
|
||||||
|
onClick={addOption}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.isArray(currentField.options) &&
|
||||||
|
currentField.options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{option}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'radio' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Options des boutons radio
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newOption}
|
||||||
|
onChange={(e) => setNewOption(e.target.value)}
|
||||||
|
placeholder="Nouvelle option"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === 'Enter' && (e.preventDefault(), addOption())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
text="Ajouter"
|
||||||
|
onClick={addOption}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Array.isArray(currentField.options) &&
|
||||||
|
currentField.options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||||
|
>
|
||||||
|
<span>{option}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'phone' && (
|
||||||
|
<Controller
|
||||||
|
name="validation.pattern"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.validation?.pattern || ''}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Format de téléphone (optionnel, exemple: ^\\+?[0-9]{10,15}$)"
|
||||||
|
name="phonePattern"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
validation: {
|
||||||
|
...currentField.validation,
|
||||||
|
pattern: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'file' && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="acceptTypes"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.acceptTypes || ''}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Types de fichiers acceptés (ex: .pdf,.jpg,.png)"
|
||||||
|
name="acceptTypes"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
acceptTypes: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="maxSize"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.maxSize || 5}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Taille maximale (MB)"
|
||||||
|
name="maxSize"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
maxSize: parseInt(e.target.value) || 5,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'signature' && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="backgroundColor"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.backgroundColor || '#ffffff'}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Couleur de fond
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
backgroundColor: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="penColor"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.penColor || '#000000'}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Couleur du stylo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
penColor: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="penWidth"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.penWidth || 2}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<InputTextIcon
|
||||||
|
label="Épaisseur du stylo (px)"
|
||||||
|
name="penWidth"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(parseInt(e.target.value));
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
penWidth: parseInt(e.target.value) || 2,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'checkbox' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<Controller
|
||||||
|
name="checked"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.checked || false}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="defaultChecked"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.checked);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
checked: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="defaultChecked">Coché par défaut</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<Controller
|
||||||
|
name="horizontal"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.horizontal || false}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="horizontal"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.checked);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
horizontal: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="horizontal">Label au-dessus (horizontal)</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentField.type === 'toggle' && (
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<Controller
|
||||||
|
name="checked"
|
||||||
|
control={control}
|
||||||
|
defaultValue={currentField.checked || false}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="defaultToggled"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.checked);
|
||||||
|
setCurrentField({
|
||||||
|
...currentField,
|
||||||
|
checked: e.target.checked,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="defaultToggled">Activé par défaut</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
text={isEditing ? 'Modifier' : 'Ajouter'}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
text="Annuler"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Sélecteur d'icônes - déplacé en dehors du formulaire */}
|
||||||
|
<IconSelector
|
||||||
|
isOpen={showIconPicker}
|
||||||
|
onClose={() => setShowIconPicker(false)}
|
||||||
|
onSelect={selectIcon}
|
||||||
|
selectedIcon={currentField.icon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FIELD_TYPES } from './FormTypes';
|
||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
// Utiliser les mêmes icônes que dans FormTemplateBuilder
|
||||||
|
const FIELD_TYPES_ICON = {
|
||||||
|
text: { icon: LucideIcons.TextCursorInput },
|
||||||
|
email: { icon: LucideIcons.AtSign },
|
||||||
|
phone: { icon: LucideIcons.Phone },
|
||||||
|
date: { icon: LucideIcons.Calendar },
|
||||||
|
select: { icon: LucideIcons.ChevronDown },
|
||||||
|
radio: { icon: LucideIcons.Radio },
|
||||||
|
checkbox: { icon: LucideIcons.CheckSquare },
|
||||||
|
toggle: { icon: LucideIcons.ToggleLeft },
|
||||||
|
file: { icon: LucideIcons.FileUp },
|
||||||
|
signature: { icon: LucideIcons.PenTool },
|
||||||
|
textarea: { icon: LucideIcons.Type },
|
||||||
|
paragraph: { icon: LucideIcons.AlignLeft },
|
||||||
|
heading1: { icon: LucideIcons.Heading1 },
|
||||||
|
heading2: { icon: LucideIcons.Heading2 },
|
||||||
|
heading3: { icon: LucideIcons.Heading3 },
|
||||||
|
heading4: { icon: LucideIcons.Heading4 },
|
||||||
|
heading5: { icon: LucideIcons.Heading5 },
|
||||||
|
heading6: { icon: LucideIcons.Heading6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FieldTypeSelector({ isOpen, onClose, onSelect }) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Filtrer les types de champs selon le terme de recherche
|
||||||
|
const filteredFieldTypes = FIELD_TYPES.filter((fieldType) =>
|
||||||
|
fieldType.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectFieldType = (fieldType) => {
|
||||||
|
onSelect(fieldType);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Choisir un type de champ ({filteredFieldTypes.length} /{' '}
|
||||||
|
{FIELD_TYPES.length} types)
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de recherche */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher un type de champ..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<LucideIcons.Search
|
||||||
|
className="absolute left-3 top-3.5 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<LucideIcons.X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{filteredFieldTypes.map((fieldType) => {
|
||||||
|
const IconComponent = FIELD_TYPES_ICON[fieldType.value]?.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={fieldType.value}
|
||||||
|
onClick={() => selectFieldType(fieldType)}
|
||||||
|
className="p-5 rounded-lg border-2 border-gray-200 bg-gray-50
|
||||||
|
hover:bg-blue-50 hover:border-blue-300 hover:shadow-md hover:scale-105
|
||||||
|
flex flex-col items-center justify-center gap-4 min-h-[140px] w-full
|
||||||
|
transition-all duration-200"
|
||||||
|
title={fieldType.label}
|
||||||
|
>
|
||||||
|
{IconComponent && (
|
||||||
|
<IconComponent
|
||||||
|
size={32}
|
||||||
|
className="text-gray-700 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-600 text-center font-medium">
|
||||||
|
{fieldType.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button
|
||||||
|
text="Annuler"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import { FIELD_TYPES } from './FormTypes';
|
||||||
|
|
||||||
|
// Associer les icônes à chaque type de champ
|
||||||
|
export const FIELD_TYPES_WITH_ICONS = FIELD_TYPES.map((fieldType) => {
|
||||||
|
let icon = null;
|
||||||
|
|
||||||
|
switch (fieldType.value) {
|
||||||
|
case 'text':
|
||||||
|
icon = LucideIcons.TextCursorInput;
|
||||||
|
break;
|
||||||
|
case 'email':
|
||||||
|
icon = LucideIcons.AtSign;
|
||||||
|
break;
|
||||||
|
case 'phone':
|
||||||
|
icon = LucideIcons.Phone;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
icon = LucideIcons.Calendar;
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
icon = LucideIcons.ChevronDown;
|
||||||
|
break;
|
||||||
|
case 'radio':
|
||||||
|
icon = LucideIcons.Radio;
|
||||||
|
break;
|
||||||
|
case 'checkbox':
|
||||||
|
icon = LucideIcons.CheckSquare;
|
||||||
|
break;
|
||||||
|
case 'toggle':
|
||||||
|
icon = LucideIcons.ToggleLeft;
|
||||||
|
break;
|
||||||
|
case 'file':
|
||||||
|
icon = LucideIcons.FileUp;
|
||||||
|
break;
|
||||||
|
case 'signature':
|
||||||
|
icon = LucideIcons.PenTool;
|
||||||
|
break;
|
||||||
|
case 'textarea':
|
||||||
|
icon = LucideIcons.Type;
|
||||||
|
break;
|
||||||
|
case 'paragraph':
|
||||||
|
icon = LucideIcons.AlignLeft;
|
||||||
|
break;
|
||||||
|
case 'heading1':
|
||||||
|
icon = LucideIcons.Heading1;
|
||||||
|
break;
|
||||||
|
case 'heading2':
|
||||||
|
icon = LucideIcons.Heading2;
|
||||||
|
break;
|
||||||
|
case 'heading3':
|
||||||
|
icon = LucideIcons.Heading3;
|
||||||
|
break;
|
||||||
|
case 'heading4':
|
||||||
|
icon = LucideIcons.Heading4;
|
||||||
|
break;
|
||||||
|
case 'heading5':
|
||||||
|
icon = LucideIcons.Heading5;
|
||||||
|
break;
|
||||||
|
case 'heading6':
|
||||||
|
icon = LucideIcons.Heading6;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fieldType,
|
||||||
|
icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FIELD_TYPES_WITH_ICONS;
|
||||||
457
Front-End/src/components/Form/FormRenderer.js
Normal file
457
Front-End/src/components/Form/FormRenderer.js
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
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';
|
||||||
|
import SignatureField from './SignatureField';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormRenderer({
|
||||||
|
formConfig,
|
||||||
|
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) ||
|
||||||
|
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasFiles) {
|
||||||
|
// Utiliser FormData pour l'envoi de fichiers
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Ajouter l'ID du formulaire
|
||||||
|
formData.append('formId', (formConfig?.id || 'unknown').toString());
|
||||||
|
|
||||||
|
// Traiter chaque champ et ses valeurs
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
const value = data[key];
|
||||||
|
|
||||||
|
if (
|
||||||
|
value instanceof FileList ||
|
||||||
|
(value && value[0] instanceof File)
|
||||||
|
) {
|
||||||
|
// Gérer les champs de type fichier
|
||||||
|
if (value.length > 0) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
formData.append(`files.${key}`, value[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.startsWith('data:image')
|
||||||
|
) {
|
||||||
|
// Gérer les signatures (SVG ou images base64)
|
||||||
|
if (value.includes('svg+xml')) {
|
||||||
|
// Gérer les signatures SVG
|
||||||
|
const svgData = value.split(',')[1];
|
||||||
|
const svgBlob = new Blob([atob(svgData)], {
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
});
|
||||||
|
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
|
||||||
|
} else {
|
||||||
|
// Gérer les images base64 classiques
|
||||||
|
const byteString = atob(value.split(',')[1]);
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
|
ia[i] = byteString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([ab], { type: 'image/png' });
|
||||||
|
formData.append(`files.${key}`, blob, `signature_${key}.png`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Gérer les autres types de champs
|
||||||
|
formData.append(
|
||||||
|
`data.${key}`,
|
||||||
|
value !== undefined ? value.toString() : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onFormSubmit) {
|
||||||
|
// Utiliser le callback personnalisé si fourni
|
||||||
|
await onFormSubmit(formData, true);
|
||||||
|
} else {
|
||||||
|
// Sinon, utiliser la fonction par défaut
|
||||||
|
await sendFormDataToBackend(formData);
|
||||||
|
alert('Formulaire avec fichier(s) envoyé avec succès');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pas de fichier, on peut utiliser JSON
|
||||||
|
const formattedData = {
|
||||||
|
formId: formConfig?.id || 'unknown',
|
||||||
|
responses: { ...data },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onFormSubmit) {
|
||||||
|
// Utiliser le callback personnalisé si fourni
|
||||||
|
await onFormSubmit(formattedData, false);
|
||||||
|
} else {
|
||||||
|
// Afficher un message pour démonstration
|
||||||
|
alert('Données reçues : ' + JSON.stringify(formattedData, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(); // Réinitialiser le formulaire après soumission
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la soumission du formulaire:', error);
|
||||||
|
alert(`Erreur lors de l'envoi du formulaire: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || 'Formulaire'}
|
||||||
|
</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'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.type === 'signature' && (
|
||||||
|
<Controller
|
||||||
|
name={field.id}
|
||||||
|
control={control}
|
||||||
|
rules={{ required: field.required }}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<div>
|
||||||
|
<SignatureField
|
||||||
|
label={field.label}
|
||||||
|
required={field.required}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={onChange}
|
||||||
|
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||||
|
penColor={field.penColor || '#000000'}
|
||||||
|
penWidth={field.penWidth || 2}
|
||||||
|
/>
|
||||||
|
{errors[field.id] && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">
|
||||||
|
{field.required
|
||||||
|
? `${field.label} est requis`
|
||||||
|
: 'Champ invalide'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="form-group-submit mt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
primary
|
||||||
|
text={formConfig?.submitLabel || 'Envoyer'}
|
||||||
|
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
703
Front-End/src/components/Form/FormTemplateBuilder.js
Normal file
703
Front-End/src/components/Form/FormTemplateBuilder.js
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import InputTextIcon from './InputTextIcon';
|
||||||
|
import FormRenderer from './FormRenderer';
|
||||||
|
import AddFieldModal from './AddFieldModal';
|
||||||
|
import FieldTypeSelector from './FieldTypeSelector';
|
||||||
|
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||||
|
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||||
|
import {
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
PlusCircle,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
GripVertical,
|
||||||
|
TextCursorInput,
|
||||||
|
AtSign,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
Type,
|
||||||
|
AlignLeft,
|
||||||
|
Save,
|
||||||
|
ChevronUp,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Heading4,
|
||||||
|
Heading5,
|
||||||
|
Heading6,
|
||||||
|
Code,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Phone,
|
||||||
|
Radio,
|
||||||
|
ToggleLeft,
|
||||||
|
CheckSquare,
|
||||||
|
FileUp,
|
||||||
|
PenTool,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import CheckBox from '@/components/Form/CheckBox';
|
||||||
|
|
||||||
|
const FIELD_TYPES_ICON = {
|
||||||
|
text: { icon: TextCursorInput },
|
||||||
|
email: { icon: AtSign },
|
||||||
|
phone: { icon: Phone },
|
||||||
|
date: { icon: Calendar },
|
||||||
|
select: { icon: ChevronDown },
|
||||||
|
radio: { icon: Radio },
|
||||||
|
checkbox: { icon: CheckSquare },
|
||||||
|
toggle: { icon: ToggleLeft },
|
||||||
|
file: { icon: FileUp },
|
||||||
|
signature: { icon: PenTool },
|
||||||
|
textarea: { icon: Type },
|
||||||
|
paragraph: { icon: AlignLeft },
|
||||||
|
heading1: { icon: Heading1 },
|
||||||
|
heading2: { icon: Heading2 },
|
||||||
|
heading3: { icon: Heading3 },
|
||||||
|
heading4: { icon: Heading4 },
|
||||||
|
heading5: { icon: Heading5 },
|
||||||
|
heading6: { icon: Heading6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type d'item pour le drag and drop
|
||||||
|
const ItemTypes = {
|
||||||
|
FIELD: 'field',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Composant pour un champ draggable
|
||||||
|
const DraggableFieldItem = ({
|
||||||
|
field,
|
||||||
|
index,
|
||||||
|
moveField,
|
||||||
|
editField,
|
||||||
|
deleteField,
|
||||||
|
}) => {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
|
||||||
|
// Configuration du drag (ce qu'on peut déplacer)
|
||||||
|
const [{ isDragging }, drag] = useDrag({
|
||||||
|
type: ItemTypes.FIELD,
|
||||||
|
item: { index },
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuration du drop (où on peut déposer)
|
||||||
|
const [, drop] = useDrop({
|
||||||
|
accept: ItemTypes.FIELD,
|
||||||
|
hover: (item, monitor) => {
|
||||||
|
if (!ref.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dragIndex = item.index;
|
||||||
|
const hoverIndex = index;
|
||||||
|
|
||||||
|
// Ne rien faire si on survole le même élément
|
||||||
|
if (dragIndex === hoverIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déterminer la position de la souris par rapport à l'élément survolé
|
||||||
|
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||||
|
const hoverMiddleY =
|
||||||
|
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||||
|
const clientOffset = monitor.getClientOffset();
|
||||||
|
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||||
|
|
||||||
|
// Ne pas remplacer si on n'a pas dépassé la moitié de l'élément
|
||||||
|
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effectuer le déplacement
|
||||||
|
moveField(dragIndex, hoverIndex);
|
||||||
|
|
||||||
|
// Mettre à jour l'index de l'élément déplacé
|
||||||
|
item.index = hoverIndex;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combiner drag et drop sur le même élément de référence
|
||||||
|
drag(drop(ref));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-200 ${
|
||||||
|
isDragging ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
{FIELD_TYPES_ICON[field.type] &&
|
||||||
|
React.createElement(FIELD_TYPES_ICON[field.type].icon, {
|
||||||
|
size: 18,
|
||||||
|
className: 'text-gray-600',
|
||||||
|
})}
|
||||||
|
<span className="font-medium">
|
||||||
|
{field.type === 'paragraph'
|
||||||
|
? 'Paragraphe'
|
||||||
|
: field.type.startsWith('heading')
|
||||||
|
? `Titre ${field.type.replace('heading', '')}`
|
||||||
|
: field.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
({field.type}){field.required && ' *'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => editField(index)}
|
||||||
|
className="p-1 text-blue-500 hover:text-blue-700"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteField(index)}
|
||||||
|
className="p-1 text-red-500 hover:text-red-700"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormTemplateBuilder({
|
||||||
|
onSave,
|
||||||
|
initialData,
|
||||||
|
groups,
|
||||||
|
isEditing,
|
||||||
|
}) {
|
||||||
|
const [formConfig, setFormConfig] = useState({
|
||||||
|
id: initialData?.id || 0,
|
||||||
|
title: initialData?.name || 'Nouveau formulaire',
|
||||||
|
submitLabel: 'Envoyer',
|
||||||
|
fields: initialData?.formMasterData?.fields || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedGroups, setSelectedGroups] = useState(
|
||||||
|
initialData?.groups?.map((g) => g.id) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||||
|
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
|
||||||
|
const [selectedFieldType, setSelectedFieldType] = useState(null);
|
||||||
|
const [editingIndex, setEditingIndex] = useState(-1);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
|
||||||
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
|
const [showJsonSection, setShowJsonSection] = useState(false);
|
||||||
|
|
||||||
|
const { reset: resetField } = useForm();
|
||||||
|
|
||||||
|
// Initialiser les données du formulaire quand initialData change
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setFormConfig({
|
||||||
|
id: initialData.id || 0,
|
||||||
|
title: initialData.name || 'Nouveau formulaire',
|
||||||
|
submitLabel: 'Envoyer',
|
||||||
|
fields: initialData.formMasterData?.fields || [],
|
||||||
|
});
|
||||||
|
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// Gérer l'affichage du bouton de défilement
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
// Afficher le bouton quand on descend d'au moins 300px
|
||||||
|
setShowScrollButton(window.scrollY > 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter l'écouteur d'événement
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Nettoyage de l'écouteur lors du démontage du composant
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fonction pour remonter en haut de la page
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Générer un ID unique pour les champs
|
||||||
|
const generateFieldId = (label) => {
|
||||||
|
return label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[àáâãäå]/g, 'a')
|
||||||
|
.replace(/[èéêë]/g, 'e')
|
||||||
|
.replace(/[ìíîï]/g, 'i')
|
||||||
|
.replace(/[òóôõö]/g, 'o')
|
||||||
|
.replace(/[ùúûü]/g, 'u')
|
||||||
|
.replace(/[ç]/g, 'c')
|
||||||
|
.replace(/[^a-z0-9]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_|_$/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter ou modifier un champ
|
||||||
|
const handleFieldSubmit = (data, currentField, editIndex) => {
|
||||||
|
const isHeadingType = data.type.startsWith('heading');
|
||||||
|
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
||||||
|
|
||||||
|
const fieldData = {
|
||||||
|
...data,
|
||||||
|
id: isContentTypeOnly
|
||||||
|
? undefined
|
||||||
|
: generateFieldId(data.label || 'field'),
|
||||||
|
options: ['select', 'radio'].includes(data.type)
|
||||||
|
? Array.isArray(currentField.options)
|
||||||
|
? currentField.options
|
||||||
|
: []
|
||||||
|
: undefined,
|
||||||
|
icon: data.icon || currentField.icon || undefined,
|
||||||
|
placeholder: data.placeholder || undefined,
|
||||||
|
text: isContentTypeOnly ? data.text : undefined,
|
||||||
|
checked: ['checkbox', 'toggle'].includes(data.type)
|
||||||
|
? currentField.checked
|
||||||
|
: undefined,
|
||||||
|
horizontal:
|
||||||
|
data.type === 'checkbox' ? currentField.horizontal : undefined,
|
||||||
|
acceptTypes: data.type === 'file' ? currentField.acceptTypes : undefined,
|
||||||
|
maxSize: data.type === 'file' ? currentField.maxSize : undefined,
|
||||||
|
validation: ['phone', 'email', 'text'].includes(data.type)
|
||||||
|
? currentField.validation
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nettoyer les propriétés undefined
|
||||||
|
Object.keys(fieldData).forEach((key) => {
|
||||||
|
if (fieldData[key] === undefined || fieldData[key] === '') {
|
||||||
|
delete fieldData[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newFields = [...formConfig.fields];
|
||||||
|
|
||||||
|
if (editIndex >= 0) {
|
||||||
|
newFields[editIndex] = fieldData;
|
||||||
|
} else {
|
||||||
|
newFields.push(fieldData);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormConfig({ ...formConfig, fields: newFields });
|
||||||
|
setEditingIndex(-1);
|
||||||
|
}; // Modifier un champ existant
|
||||||
|
const editField = (index) => {
|
||||||
|
setEditingIndex(index);
|
||||||
|
setShowAddFieldModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supprimer un champ
|
||||||
|
const deleteField = (index) => {
|
||||||
|
const newFields = formConfig.fields.filter((_, i) => i !== index);
|
||||||
|
setFormConfig({ ...formConfig, fields: newFields });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Déplacer un champ
|
||||||
|
const moveField = (dragIndex, hoverIndex) => {
|
||||||
|
const newFields = [...formConfig.fields];
|
||||||
|
const draggedField = newFields[dragIndex];
|
||||||
|
|
||||||
|
// Supprimer l'élément déplacé
|
||||||
|
newFields.splice(dragIndex, 1);
|
||||||
|
|
||||||
|
// Insérer l'élément à sa nouvelle position
|
||||||
|
newFields.splice(hoverIndex, 0, draggedField);
|
||||||
|
|
||||||
|
setFormConfig({ ...formConfig, fields: newFields });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exporter le JSON
|
||||||
|
const exportJson = () => {
|
||||||
|
const jsonString = JSON.stringify(formConfig, null, 2);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `formulaire_${formConfig.title.replace(/\s+/g, '_').toLowerCase()}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Importer un JSON
|
||||||
|
const importJson = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const imported = JSON.parse(e.target.result);
|
||||||
|
setFormConfig(imported);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Erreur lors de l'importation du fichier JSON');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sauvegarder le formulaire (pour le backend)
|
||||||
|
const saveFormTemplate = async () => {
|
||||||
|
// Validation basique
|
||||||
|
if (!formConfig.title.trim()) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Le titre du formulaire est requis',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formConfig.fields.length === 0) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: 'Ajoutez au moins un champ au formulaire',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedGroups.length === 0) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: "Sélectionnez au moins un groupe d'inscription",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveMessage({ type: '', text: '' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToSave = {
|
||||||
|
name: formConfig.title,
|
||||||
|
group_ids: selectedGroups,
|
||||||
|
formMasterData: formConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing && initialData) {
|
||||||
|
dataToSave.id = initialData.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSave) {
|
||||||
|
onSave(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Formulaire enregistré avec succès',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text:
|
||||||
|
error.message || "Une erreur est survenue lors de l'enregistrement",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour gérer la sélection d'un type de champ
|
||||||
|
const handleFieldTypeSelect = (fieldType) => {
|
||||||
|
setSelectedFieldType(fieldType);
|
||||||
|
setShowFieldTypeSelector(false);
|
||||||
|
setShowAddFieldModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
{/* Panel de configuration */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
showJsonSection
|
||||||
|
? 'lg:col-span-3 space-y-6'
|
||||||
|
: 'lg:col-span-5 space-y-6'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-xl font-bold mb-6">
|
||||||
|
Configuration du formulaire
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Configuration générale */}
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold mr-4">
|
||||||
|
Paramètres généraux
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowJsonSection(!showJsonSection)}
|
||||||
|
className="px-4 py-2 rounded-md inline-flex items-center gap-2 bg-gray-500 hover:bg-gray-600 text-white"
|
||||||
|
title={
|
||||||
|
showJsonSection ? 'Masquer le JSON' : 'Afficher le JSON'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showJsonSection ? (
|
||||||
|
<EyeOff size={18} />
|
||||||
|
) : (
|
||||||
|
<Eye size={18} />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{showJsonSection ? 'Masquer JSON' : 'Afficher JSON'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveFormTemplate}
|
||||||
|
disabled={saving}
|
||||||
|
className={`px-4 py-2 rounded-md inline-flex items-center gap-2 ${
|
||||||
|
saving
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||||
|
}`}
|
||||||
|
title="Enregistrer le formulaire"
|
||||||
|
>
|
||||||
|
<Save size={18} />
|
||||||
|
<span>
|
||||||
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveMessage.text && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded ${
|
||||||
|
saveMessage.type === 'error'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputTextIcon
|
||||||
|
label="Titre du formulaire"
|
||||||
|
name="title"
|
||||||
|
value={formConfig.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormConfig({ ...formConfig, title: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputTextIcon
|
||||||
|
label="Texte du bouton de soumission du formulaire"
|
||||||
|
name="submitLabel"
|
||||||
|
value={formConfig.submitLabel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormConfig({
|
||||||
|
...formConfig,
|
||||||
|
submitLabel: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sélecteur de groupes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Groupes d'inscription{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||||
|
{groups && groups.length > 0 ? (
|
||||||
|
groups.map((group) => (
|
||||||
|
<CheckBox
|
||||||
|
key={group.id}
|
||||||
|
item={{ id: group.id }}
|
||||||
|
formData={{
|
||||||
|
groups: selectedGroups
|
||||||
|
}}
|
||||||
|
handleChange={() => {
|
||||||
|
let group_ids = selectedGroups;
|
||||||
|
if (group_ids.includes(group.id)) {
|
||||||
|
group_ids = group_ids.filter((id) => id !== group.id);
|
||||||
|
} else {
|
||||||
|
group_ids = [...group_ids, group.id];
|
||||||
|
}
|
||||||
|
setSelectedGroups(group_ids);
|
||||||
|
}}
|
||||||
|
fieldName="groups"
|
||||||
|
itemLabelFunc={() => group.name}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Aucun groupe disponible
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des champs */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold mr-4">
|
||||||
|
Champs du formulaire ({formConfig.fields.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingIndex(-1);
|
||||||
|
setSelectedFieldType(null);
|
||||||
|
setShowFieldTypeSelector(true);
|
||||||
|
}}
|
||||||
|
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||||
|
title="Ajouter un champ"
|
||||||
|
>
|
||||||
|
<PlusCircle size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formConfig.fields.length === 0 ? (
|
||||||
|
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
|
||||||
|
<p className="text-gray-500 italic mb-4">
|
||||||
|
Aucun champ ajouté
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingIndex(-1);
|
||||||
|
setSelectedFieldType(null);
|
||||||
|
setShowFieldTypeSelector(true);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusCircle size={18} />
|
||||||
|
<span>Ajouter mon premier champ</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formConfig.fields.map((field, index) => (
|
||||||
|
<DraggableFieldItem
|
||||||
|
key={index}
|
||||||
|
field={field}
|
||||||
|
index={index}
|
||||||
|
moveField={moveField}
|
||||||
|
editField={editField}
|
||||||
|
deleteField={deleteField}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* Les actions ont été déplacées dans la section JSON généré */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON généré */}
|
||||||
|
{showJsonSection && (
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow h-full">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold mr-4">JSON généré</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={exportJson}
|
||||||
|
className="p-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
|
||||||
|
title="Exporter JSON"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
className="p-2 bg-orange-500 text-white rounded-md hover:bg-orange-600 cursor-pointer transition-colors"
|
||||||
|
title="Importer JSON"
|
||||||
|
>
|
||||||
|
<Upload size={18} />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={importJson}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
|
||||||
|
{JSON.stringify(formConfig, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aperçu */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
||||||
|
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
||||||
|
{formConfig.fields.length > 0 ? (
|
||||||
|
<FormRenderer formConfig={formConfig} />
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 italic text-center">
|
||||||
|
Ajoutez des champs pour voir l'aperçu
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal d'ajout/modification de champ */}
|
||||||
|
<AddFieldModal
|
||||||
|
isOpen={showAddFieldModal}
|
||||||
|
onClose={() => setShowAddFieldModal(false)}
|
||||||
|
onSubmit={handleFieldSubmit}
|
||||||
|
editingField={
|
||||||
|
editingIndex >= 0
|
||||||
|
? formConfig.fields[editingIndex]
|
||||||
|
: selectedFieldType
|
||||||
|
? { type: selectedFieldType.value || selectedFieldType }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
editingIndex={editingIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sélecteur de type de champ */}
|
||||||
|
<FieldTypeSelector
|
||||||
|
isOpen={showFieldTypeSelector}
|
||||||
|
onClose={() => setShowFieldTypeSelector(false)}
|
||||||
|
onSelect={handleFieldTypeSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bouton flottant pour remonter en haut */}
|
||||||
|
{showScrollButton && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-10">
|
||||||
|
<button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
className="p-4 rounded-full shadow-lg flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white transition-all duration-300"
|
||||||
|
title="Remonter en haut de la page"
|
||||||
|
>
|
||||||
|
<ChevronUp size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DndProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
Front-End/src/components/Form/FormTypes.js
Normal file
20
Front-End/src/components/Form/FormTypes.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export const FIELD_TYPES = [
|
||||||
|
{ value: 'text', label: 'Texte' },
|
||||||
|
{ value: 'email', label: 'Email' },
|
||||||
|
{ value: 'phone', label: 'Téléphone' },
|
||||||
|
{ value: 'date', label: 'Date' },
|
||||||
|
{ value: 'select', label: 'Liste déroulante' },
|
||||||
|
{ value: 'radio', label: 'Boutons radio' },
|
||||||
|
{ value: 'checkbox', label: 'Case à cocher' },
|
||||||
|
{ value: 'toggle', label: 'Interrupteur' },
|
||||||
|
{ value: 'file', label: 'Upload de fichier' },
|
||||||
|
{ value: 'signature', label: 'Signature' },
|
||||||
|
{ value: 'textarea', label: 'Zone de texte riche' },
|
||||||
|
{ value: 'paragraph', label: 'Paragraphe' },
|
||||||
|
{ value: 'heading1', label: 'Titre 1' },
|
||||||
|
{ value: 'heading2', label: 'Titre 2' },
|
||||||
|
{ value: 'heading3', label: 'Titre 3' },
|
||||||
|
{ value: 'heading4', label: 'Titre 4' },
|
||||||
|
{ value: 'heading5', label: 'Titre 5' },
|
||||||
|
{ value: 'heading6', label: 'Titre 6' },
|
||||||
|
];
|
||||||
145
Front-End/src/components/Form/IconSelector.js
Normal file
145
Front-End/src/components/Form/IconSelector.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
236
Front-End/src/components/Form/SelectChoice.js
Normal file
236
Front-End/src/components/Form/SelectChoice.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function SelectChoice({
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
placeHolder,
|
||||||
|
choices,
|
||||||
|
callback,
|
||||||
|
selected,
|
||||||
|
errorMsg,
|
||||||
|
errorLocalMsg,
|
||||||
|
IconItem,
|
||||||
|
disabled = false,
|
||||||
|
// Nouveaux paramètres
|
||||||
|
showIcons = false, // Activer l'affichage des icônes dans les options
|
||||||
|
customSelect = false, // Utiliser une liste personnalisée au lieu du select natif
|
||||||
|
}) {
|
||||||
|
const isPlaceholderSelected = selected === ''; // Vérifie si le placeholder est sélectionné
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedLabel, setSelectedLabel] = useState('');
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// Ferme le dropdown si on clique en dehors
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customSelect) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [customSelect]);
|
||||||
|
|
||||||
|
// Met à jour le label affiché quand selected change
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected && choices) {
|
||||||
|
const selectedOption = choices.find(
|
||||||
|
(option) => option.value === selected
|
||||||
|
);
|
||||||
|
if (selectedOption) {
|
||||||
|
setSelectedLabel(selectedOption.label);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedLabel('');
|
||||||
|
}
|
||||||
|
}, [selected, choices]);
|
||||||
|
|
||||||
|
// Affiche soit le select natif, soit notre version personnalisée avec icônes
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={name}
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{customSelect ? (
|
||||||
|
// Version personnalisée avec icônes dans un dropdown
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<div
|
||||||
|
className={`mt-1 flex items-center border rounded-md cursor-pointer ${
|
||||||
|
errorMsg || errorLocalMsg
|
||||||
|
? 'border-red-500 hover:border-red-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-400'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{IconItem && (
|
||||||
|
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||||
|
{<IconItem />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 px-3 py-2 block w-full sm:text-sm">
|
||||||
|
{isPlaceholderSelected ? (
|
||||||
|
<span className="italic text-gray-500">
|
||||||
|
{placeHolder?.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{showIcons && selected && choices && (
|
||||||
|
<span className="mr-2">
|
||||||
|
{(() => {
|
||||||
|
const selectedOption = choices.find(
|
||||||
|
(option) => option.value === selected
|
||||||
|
);
|
||||||
|
if (selectedOption && selectedOption.icon) {
|
||||||
|
const IconComponent = selectedOption.icon;
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
size={18}
|
||||||
|
className="text-gray-600"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">{selectedLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pr-2">
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full bg-white shadow-lg rounded-md py-1 max-h-60 overflow-auto">
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 text-gray-500 italic cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
const event = { target: { value: '' } };
|
||||||
|
callback(event);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeHolder?.toLowerCase()}
|
||||||
|
</div>
|
||||||
|
{choices.map(({ value, label, icon: Icon }) => (
|
||||||
|
<div
|
||||||
|
key={value}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 ${
|
||||||
|
value === selected
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-800'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
const event = { target: { value } };
|
||||||
|
callback(event);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{showIcons && Icon && (
|
||||||
|
<span className="mr-2">
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className={
|
||||||
|
value === selected
|
||||||
|
? 'text-blue-600'
|
||||||
|
: 'text-gray-600'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input caché pour la compatibilité avec les formulaires */}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={name}
|
||||||
|
id={name}
|
||||||
|
value={selected || ''}
|
||||||
|
onChange={() => {}} // Évite l'avertissement React pour input non contrôlé
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Version standard avec select natif (pour la rétrocompatibilité)
|
||||||
|
<div
|
||||||
|
className={`mt-1 flex items-center border rounded-md ${
|
||||||
|
errorMsg || errorLocalMsg
|
||||||
|
? 'border-red-500 hover:border-red-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-400'
|
||||||
|
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
||||||
|
>
|
||||||
|
{IconItem && (
|
||||||
|
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||||
|
{<IconItem />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
||||||
|
disabled ? 'bg-gray-100' : ''
|
||||||
|
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`}
|
||||||
|
type={type}
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
value={selected}
|
||||||
|
onChange={callback}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{/* Placeholder en italique */}
|
||||||
|
<option value="" className="italic text-gray-500">
|
||||||
|
{placeHolder?.toLowerCase()}
|
||||||
|
</option>
|
||||||
|
{/* Autres options sans italique */}
|
||||||
|
{choices.map(({ value, label }) => (
|
||||||
|
<option
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
className="not-italic text-gray-800"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
Front-End/src/components/Form/SignatureField.js
Normal file
346
Front-End/src/components/Form/SignatureField.js
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
const SignatureField = ({
|
||||||
|
label = 'Signature',
|
||||||
|
required = false,
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
backgroundColor = '#ffffff',
|
||||||
|
penColor = '#000000',
|
||||||
|
penWidth = 2,
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [lastPosition, setLastPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
|
const [svgPaths, setSvgPaths] = useState([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState('');
|
||||||
|
const [smoothingPoints, setSmoothingPoints] = useState([]);
|
||||||
|
|
||||||
|
// Initialiser le canvas
|
||||||
|
const initializeCanvas = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Support High DPI / Retina displays
|
||||||
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
|
const displayWidth = 400;
|
||||||
|
const displayHeight = 200;
|
||||||
|
|
||||||
|
// Ajuster la taille physique du canvas pour la haute résolution
|
||||||
|
canvas.width = displayWidth * devicePixelRatio;
|
||||||
|
canvas.height = displayHeight * devicePixelRatio;
|
||||||
|
|
||||||
|
// Maintenir la taille d'affichage
|
||||||
|
canvas.style.width = displayWidth + 'px';
|
||||||
|
canvas.style.height = displayHeight + 'px';
|
||||||
|
|
||||||
|
// Adapter le contexte à la densité de pixels
|
||||||
|
context.scale(devicePixelRatio, devicePixelRatio);
|
||||||
|
|
||||||
|
// Améliorer l'anti-aliasing et le rendu
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.imageSmoothingQuality = 'high';
|
||||||
|
context.textRenderingOptimization = 'optimizeQuality';
|
||||||
|
|
||||||
|
// Configuration du style de dessin
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
|
context.strokeStyle = penColor;
|
||||||
|
context.lineWidth = penWidth;
|
||||||
|
context.lineCap = 'round';
|
||||||
|
context.lineJoin = 'round';
|
||||||
|
context.globalCompositeOperation = 'source-over';
|
||||||
|
}, [backgroundColor, penColor, penWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initializeCanvas();
|
||||||
|
|
||||||
|
// Si une valeur est fournie (signature existante), la charger
|
||||||
|
if (value && value !== '') {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (value.includes('svg+xml')) {
|
||||||
|
// Charger une signature SVG
|
||||||
|
const svgData = atob(value.split(',')[1]);
|
||||||
|
const img = new Image();
|
||||||
|
const svg = new Blob([svgData], {
|
||||||
|
type: 'image/svg+xml;charset=utf-8',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(svg);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(img, 0, 0);
|
||||||
|
setIsEmpty(false);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
} else {
|
||||||
|
// Charger une image classique
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(img, 0, 0);
|
||||||
|
setIsEmpty(false);
|
||||||
|
};
|
||||||
|
img.src = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, initializeCanvas, backgroundColor]);
|
||||||
|
|
||||||
|
// Obtenir les coordonnées relatives au canvas
|
||||||
|
const getCanvasPosition = (e) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.style.width
|
||||||
|
? parseFloat(canvas.style.width) / rect.width
|
||||||
|
: 1;
|
||||||
|
const scaleY = canvas.style.height
|
||||||
|
? parseFloat(canvas.style.height) / rect.height
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * scaleX,
|
||||||
|
y: (e.clientY - rect.top) * scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtenir les coordonnées pour les événements tactiles
|
||||||
|
const getTouchPosition = (e) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = canvas.style.width
|
||||||
|
? parseFloat(canvas.style.width) / rect.width
|
||||||
|
: 1;
|
||||||
|
const scaleY = canvas.style.height
|
||||||
|
? parseFloat(canvas.style.height) / rect.height
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (e.touches[0].clientX - rect.left) * scaleX,
|
||||||
|
y: (e.touches[0].clientY - rect.top) * scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Commencer le dessin
|
||||||
|
const startDrawing = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (disabled || readOnly) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDrawing(true);
|
||||||
|
|
||||||
|
const pos = e.type.includes('touch')
|
||||||
|
? getTouchPosition(e)
|
||||||
|
: getCanvasPosition(e);
|
||||||
|
setLastPosition(pos);
|
||||||
|
|
||||||
|
// Commencer un nouveau path SVG
|
||||||
|
setCurrentPath(`M ${pos.x},${pos.y}`);
|
||||||
|
setSmoothingPoints([pos]);
|
||||||
|
},
|
||||||
|
[disabled, readOnly]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dessiner
|
||||||
|
const draw = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!isDrawing || disabled || readOnly) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const currentPos = e.type.includes('touch')
|
||||||
|
? getTouchPosition(e)
|
||||||
|
: getCanvasPosition(e);
|
||||||
|
|
||||||
|
// Calculer la distance pour déterminer si on doit interpoler
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(currentPos.x - lastPosition.x, 2) +
|
||||||
|
Math.pow(currentPos.y - lastPosition.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si la distance est grande, interpoler pour un tracé plus lisse
|
||||||
|
if (distance > 2) {
|
||||||
|
const midPoint = {
|
||||||
|
x: (lastPosition.x + currentPos.x) / 2,
|
||||||
|
y: (lastPosition.y + currentPos.y) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utiliser une courbe quadratique pour un tracé plus lisse
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(lastPosition.x, lastPosition.y);
|
||||||
|
context.quadraticCurveTo(
|
||||||
|
lastPosition.x,
|
||||||
|
lastPosition.y,
|
||||||
|
midPoint.x,
|
||||||
|
midPoint.y
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
setLastPosition(midPoint);
|
||||||
|
setCurrentPath(
|
||||||
|
(prev) =>
|
||||||
|
prev +
|
||||||
|
` Q ${lastPosition.x},${lastPosition.y} ${midPoint.x},${midPoint.y}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Tracé direct pour les mouvements lents
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(lastPosition.x, lastPosition.y);
|
||||||
|
context.lineTo(currentPos.x, currentPos.y);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
setLastPosition(currentPos);
|
||||||
|
setCurrentPath((prev) => prev + ` L ${currentPos.x},${currentPos.y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEmpty(false);
|
||||||
|
},
|
||||||
|
[isDrawing, lastPosition, disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arrêter le dessin
|
||||||
|
const stopDrawing = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDrawing(false);
|
||||||
|
|
||||||
|
// Ajouter le path terminé aux paths SVG
|
||||||
|
if (currentPath) {
|
||||||
|
setSvgPaths((prev) => [...prev, currentPath]);
|
||||||
|
setCurrentPath('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier le parent du changement avec SVG
|
||||||
|
if (onChange) {
|
||||||
|
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
||||||
|
const svgData = generateSVG(newPaths);
|
||||||
|
onChange(svgData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDrawing, onChange, svgPaths, currentPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Générer le SVG à partir des paths
|
||||||
|
const generateSVG = (paths) => {
|
||||||
|
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||||
|
${paths
|
||||||
|
.map(
|
||||||
|
(path) =>
|
||||||
|
`<path d="${path}" stroke="${penColor}" stroke-width="${penWidth}" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`
|
||||||
|
)
|
||||||
|
.join('\n ')}
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effacer la signature
|
||||||
|
const clearSignature = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Effacer en tenant compte des dimensions d'affichage
|
||||||
|
const displayWidth = 400;
|
||||||
|
const displayHeight = 200;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
|
setIsEmpty(true);
|
||||||
|
setSvgPaths([]);
|
||||||
|
setCurrentPath('');
|
||||||
|
setSmoothingPoints([]);
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signature-field">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||||
|
readOnly ? 'cursor-default' : 'cursor-crosshair'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
opacity: disabled || readOnly ? 0.7 : 1,
|
||||||
|
cursor: disabled
|
||||||
|
? 'not-allowed'
|
||||||
|
: readOnly
|
||||||
|
? 'default'
|
||||||
|
: 'crosshair',
|
||||||
|
}}
|
||||||
|
onMouseDown={readOnly ? undefined : startDrawing}
|
||||||
|
onMouseMove={readOnly ? undefined : draw}
|
||||||
|
onMouseUp={readOnly ? undefined : stopDrawing}
|
||||||
|
onMouseLeave={readOnly ? undefined : stopDrawing}
|
||||||
|
onTouchStart={readOnly ? undefined : startDrawing}
|
||||||
|
onTouchMove={readOnly ? undefined : draw}
|
||||||
|
onTouchEnd={readOnly ? undefined : stopDrawing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-3">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{readOnly
|
||||||
|
? isEmpty
|
||||||
|
? 'Aucune signature'
|
||||||
|
: 'Signature'
|
||||||
|
: isEmpty
|
||||||
|
? 'Signez dans la zone ci-dessus'
|
||||||
|
: 'Signature capturée'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSignature}
|
||||||
|
disabled={disabled || isEmpty}
|
||||||
|
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-100 text-red-600 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{required && isEmpty && (
|
||||||
|
<div className="text-xs text-red-500 mt-1">
|
||||||
|
La signature est obligatoire
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignatureField;
|
||||||
@ -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,
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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é' },
|
||||||
|
|||||||
295
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
295
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import FormRenderer from '@/components/Form/FormRenderer';
|
||||||
|
import { CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||||
|
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
|
||||||
|
* @param {Object} existingResponses - Réponses déjà sauvegardées
|
||||||
|
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
|
||||||
|
* @param {Boolean} enable - Si les formulaires sont modifiables
|
||||||
|
*/
|
||||||
|
export default function DynamicFormsList({
|
||||||
|
schoolFileMasters,
|
||||||
|
existingResponses = {},
|
||||||
|
onFormSubmit,
|
||||||
|
enable = true,
|
||||||
|
onValidationChange,
|
||||||
|
}) {
|
||||||
|
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||||
|
const [formsData, setFormsData] = useState({});
|
||||||
|
const [formsValidation, setFormsValidation] = useState({});
|
||||||
|
|
||||||
|
// Initialiser les données avec les réponses existantes
|
||||||
|
useEffect(() => {
|
||||||
|
if (existingResponses && Object.keys(existingResponses).length > 0) {
|
||||||
|
setFormsData(existingResponses);
|
||||||
|
|
||||||
|
// Marquer les formulaires avec réponses comme valides
|
||||||
|
const validationState = {};
|
||||||
|
Object.keys(existingResponses).forEach((formId) => {
|
||||||
|
if (
|
||||||
|
existingResponses[formId] &&
|
||||||
|
Object.keys(existingResponses[formId]).length > 0
|
||||||
|
) {
|
||||||
|
validationState[formId] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormsValidation(validationState);
|
||||||
|
}
|
||||||
|
}, [existingResponses]);
|
||||||
|
|
||||||
|
// Debug: Log des formulaires maîtres reçus
|
||||||
|
useEffect(() => {
|
||||||
|
logger.debug(
|
||||||
|
'DynamicFormsList - Formulaires maîtres reçus:',
|
||||||
|
schoolFileMasters
|
||||||
|
);
|
||||||
|
}, [schoolFileMasters]);
|
||||||
|
|
||||||
|
// Mettre à jour la validation globale quand la validation des formulaires change
|
||||||
|
useEffect(() => {
|
||||||
|
const allFormsValid = schoolFileMasters.every(
|
||||||
|
(master, index) => formsValidation[master.id] === true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onValidationChange) {
|
||||||
|
onValidationChange(allFormsValid);
|
||||||
|
}
|
||||||
|
}, [formsValidation, schoolFileMasters, onValidationChange]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère la soumission d'un formulaire individuel
|
||||||
|
*/
|
||||||
|
const handleFormSubmit = async (formData, templateId) => {
|
||||||
|
try {
|
||||||
|
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||||
|
|
||||||
|
// Sauvegarder les données du formulaire
|
||||||
|
setFormsData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[templateId]: formData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Marquer le formulaire comme complété
|
||||||
|
setFormsValidation((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[templateId]: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Appeler le callback parent
|
||||||
|
if (onFormSubmit) {
|
||||||
|
await onFormSubmit(formData, templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passer au formulaire suivant si disponible
|
||||||
|
if (currentTemplateIndex < schoolFileMasters.length - 1) {
|
||||||
|
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Formulaire soumis avec succès');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la soumission du formulaire:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère les changements de validation d'un formulaire
|
||||||
|
*/
|
||||||
|
const handleFormValidationChange = (isValid, templateId) => {
|
||||||
|
setFormsValidation((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[templateId]: isValid,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si un formulaire est complété
|
||||||
|
*/
|
||||||
|
const isFormCompleted = (templateId) => {
|
||||||
|
return (
|
||||||
|
formsValidation[templateId] === true ||
|
||||||
|
(formsData[templateId] &&
|
||||||
|
Object.keys(formsData[templateId]).length > 0) ||
|
||||||
|
(existingResponses[templateId] &&
|
||||||
|
Object.keys(existingResponses[templateId]).length > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient l'icône de statut d'un formulaire
|
||||||
|
*/
|
||||||
|
const getFormStatusIcon = (templateId, isActive) => {
|
||||||
|
if (isFormCompleted(templateId)) {
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||||
|
}
|
||||||
|
if (isActive) {
|
||||||
|
return <FileText className="w-5 h-5 text-blue-600" />;
|
||||||
|
}
|
||||||
|
return <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le formulaire actuel à afficher
|
||||||
|
*/
|
||||||
|
const getCurrentTemplate = () => {
|
||||||
|
return schoolFileMasters[currentTemplateIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!schoolFileMasters || schoolFileMasters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 mb-4">Aucun formulaire à compléter</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTemplate = getCurrentTemplate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||||
|
{/* Liste des formulaires */}
|
||||||
|
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Formulaires à compléter
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-gray-600 mb-4">
|
||||||
|
{
|
||||||
|
Object.keys(formsValidation).filter((id) => formsValidation[id])
|
||||||
|
.length
|
||||||
|
}{' '}
|
||||||
|
/ {schoolFileMasters.length} complétés
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{schoolFileMasters.map((master, index) => {
|
||||||
|
const isActive = index === currentTemplateIndex;
|
||||||
|
const isCompleted = isFormCompleted(master.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={master.id}
|
||||||
|
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-100 text-blue-700 font-semibold'
|
||||||
|
: isCompleted
|
||||||
|
? 'text-green-600 hover:bg-green-50'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTemplateIndex(index)}
|
||||||
|
>
|
||||||
|
<span className="mr-3">
|
||||||
|
{getFormStatusIcon(master.id, isActive)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate">
|
||||||
|
{master.formMasterData?.title ||
|
||||||
|
master.title ||
|
||||||
|
master.name ||
|
||||||
|
'Formulaire sans nom'}
|
||||||
|
</div>
|
||||||
|
{isCompleted ? (
|
||||||
|
<div className="text-xs text-green-600">
|
||||||
|
Complété -{' '}
|
||||||
|
{
|
||||||
|
Object.keys(
|
||||||
|
formsData[master.id] ||
|
||||||
|
existingResponses[master.id] ||
|
||||||
|
{}
|
||||||
|
).length
|
||||||
|
}{' '}
|
||||||
|
réponse(s)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{master.formMasterData?.fields || master.fields
|
||||||
|
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
||||||
|
: 'À compléter'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Affichage du formulaire actuel */}
|
||||||
|
<div className="w-3/4">
|
||||||
|
{currentTemplate && (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
|
{currentTemplate.formMasterData?.title ||
|
||||||
|
currentTemplate.title ||
|
||||||
|
currentTemplate.name ||
|
||||||
|
'Formulaire sans nom'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{currentTemplate.formMasterData?.description ||
|
||||||
|
currentTemplate.description ||
|
||||||
|
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Formulaire {currentTemplateIndex + 1} sur{' '}
|
||||||
|
{schoolFileMasters.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vérifier si le formulaire maître a des données de configuration */}
|
||||||
|
{(currentTemplate.formMasterData?.fields &&
|
||||||
|
currentTemplate.formMasterData.fields.length > 0) ||
|
||||||
|
(currentTemplate.fields && currentTemplate.fields.length > 0) ? (
|
||||||
|
<FormRenderer
|
||||||
|
key={currentTemplate.id}
|
||||||
|
formConfig={{
|
||||||
|
id: currentTemplate.id,
|
||||||
|
title:
|
||||||
|
currentTemplate.formMasterData?.title ||
|
||||||
|
currentTemplate.title ||
|
||||||
|
currentTemplate.name ||
|
||||||
|
'Formulaire',
|
||||||
|
fields:
|
||||||
|
currentTemplate.formMasterData?.fields ||
|
||||||
|
currentTemplate.fields ||
|
||||||
|
[],
|
||||||
|
submitLabel:
|
||||||
|
currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||||
|
}}
|
||||||
|
onFormSubmit={(formData) =>
|
||||||
|
handleFormSubmit(formData, currentTemplate.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Ce formulaire n'est pas encore configuré.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Contactez l'administration pour plus d'informations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message de fin */}
|
||||||
|
{currentTemplateIndex >= schoolFileMasters.length && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-green-600 mb-2">
|
||||||
|
Tous les formulaires ont été complétés !
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Vous pouvez maintenant passer à l'étape suivante.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
// 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,
|
||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
|
fetchRegistrationSchoolFileMasters,
|
||||||
|
saveFormResponses,
|
||||||
|
fetchFormResponses,
|
||||||
|
autoSaveRegisterForm,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import {
|
import {
|
||||||
downloadTemplate,
|
downloadTemplate,
|
||||||
@ -21,7 +25,8 @@ import { fetchProfiles } from '@/app/actions/authAction';
|
|||||||
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
|
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import FilesToUpload from '@/components/Inscription/FilesToUpload';
|
import FilesToUpload from '@/components/Inscription/FilesToUpload';
|
||||||
import { DocusealForm } from '@docuseal/react';
|
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
|
||||||
|
import AutoSaveIndicator from '@/components/AutoSaveIndicator';
|
||||||
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
|
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
|
||||||
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
|
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
|
||||||
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
|
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
|
||||||
@ -75,6 +80,8 @@ export default function InscriptionFormShared({
|
|||||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||||
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
||||||
|
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||||
|
const [formResponses, setFormResponses] = useState({});
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const [isPage1Valid, setIsPage1Valid] = useState(false);
|
const [isPage1Valid, setIsPage1Valid] = useState(false);
|
||||||
@ -90,6 +97,9 @@ export default function InscriptionFormShared({
|
|||||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState([]);
|
const [profiles, setProfiles] = useState([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [lastSaved, setLastSaved] = useState(null);
|
||||||
|
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -121,18 +131,18 @@ export default function InscriptionFormShared({
|
|||||||
}, [schoolFileTemplates]);
|
}, [schoolFileTemplates]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Vérifier si tous les templates ont leur champ "file" différent de null
|
// Vérifier si tous les formulaires maîtres sont complétés
|
||||||
const allSigned = schoolFileTemplates.every(
|
const allCompleted =
|
||||||
(template) => template.file !== null
|
schoolFileMasters.length === 0 ||
|
||||||
);
|
schoolFileMasters.every((master) => master.completed === true);
|
||||||
|
|
||||||
// Mettre à jour isPage4Valid en fonction de cette condition
|
// Mettre à jour isPage5Valid en fonction de cette condition
|
||||||
setIsPage5Valid(allSigned);
|
setIsPage5Valid(allCompleted);
|
||||||
|
|
||||||
if (allSigned) {
|
if (allCompleted) {
|
||||||
setCurrentTemplateIndex(0);
|
setCurrentTemplateIndex(0);
|
||||||
}
|
}
|
||||||
}, [schoolFileTemplates]);
|
}, [schoolFileMasters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
|
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
|
||||||
@ -145,57 +155,216 @@ export default function InscriptionFormShared({
|
|||||||
logger.debug(allRequiredUploaded);
|
logger.debug(allRequiredUploaded);
|
||||||
}, [parentFileTemplates]);
|
}, [parentFileTemplates]);
|
||||||
|
|
||||||
const handleTemplateSigned = (index) => {
|
// Auto-sauvegarde périodique (toutes les 30 secondes)
|
||||||
const template = schoolFileTemplates[index];
|
useEffect(() => {
|
||||||
|
if (!enable || !autoSaveEnabled) return;
|
||||||
|
|
||||||
if (!template) {
|
const interval = setInterval(() => {
|
||||||
logger.error("Template introuvable pour l'index donné.");
|
autoSave();
|
||||||
|
}, 30000); // 30 secondes
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [enable, autoSaveEnabled, formData, guardians, siblings]);
|
||||||
|
|
||||||
|
// Auto-sauvegarde quand les données changent (avec debounce)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enable || !autoSaveEnabled) return;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
autoSave();
|
||||||
|
}, 2000); // Attendre 2 secondes après le dernier changement
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [formData, guardians, siblings]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonction d'auto-sauvegarde qui sauvegarde les données en cours
|
||||||
|
*/
|
||||||
|
const autoSave = async () => {
|
||||||
|
if (!autoSaveEnabled || !studentId || isSaving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Télécharger le template
|
try {
|
||||||
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
|
setIsSaving(true);
|
||||||
.then((downloadUrl) => fetch(downloadUrl))
|
logger.debug('Auto-sauvegarde en cours...', {
|
||||||
.then((response) => {
|
studentId,
|
||||||
if (!response.ok) {
|
formDataKeys: Object.keys(formData),
|
||||||
throw new Error('Erreur lors du téléchargement du fichier.');
|
paymentFields: {
|
||||||
}
|
registration_payment: formData.registration_payment,
|
||||||
return response.blob();
|
registration_payment_plan: formData.registration_payment_plan,
|
||||||
})
|
tuition_payment: formData.tuition_payment,
|
||||||
.then((blob) => {
|
tuition_payment_plan: formData.tuition_payment_plan,
|
||||||
const file = new File([blob], `${template.name}.pdf`, {
|
},
|
||||||
type: blob.type,
|
guardians: guardians.length,
|
||||||
});
|
siblings: siblings.length,
|
||||||
|
currentPage,
|
||||||
// Préparer les données pour la mise à jour
|
|
||||||
const updateData = new FormData();
|
|
||||||
updateData.append('file', file);
|
|
||||||
|
|
||||||
// Mettre à jour le template via l'API
|
|
||||||
return editRegistrationSchoolFileTemplates(
|
|
||||||
template.id,
|
|
||||||
updateData,
|
|
||||||
csrfToken
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then((updatedTemplate) => {
|
|
||||||
logger.debug('Template mis à jour avec succès :', updatedTemplate);
|
|
||||||
|
|
||||||
// Mettre à jour l'état local de schoolFileTemplates
|
|
||||||
setSchoolFileTemplates((prevTemplates) => {
|
|
||||||
const updatedTemplates = prevTemplates.map((t, i) =>
|
|
||||||
i === index ? { ...t, file: updatedTemplate.data.file } : t
|
|
||||||
);
|
|
||||||
logger.debug(
|
|
||||||
'État schoolFileTemplates mis à jour :',
|
|
||||||
updatedTemplates
|
|
||||||
);
|
|
||||||
return updatedTemplates;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error('Erreur lors de la mise à jour du template :', error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fonction helper pour nettoyer les données avant sauvegarde
|
||||||
|
const cleanDataForAutoSave = (data) => {
|
||||||
|
const cleaned = {};
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
const value = data[key];
|
||||||
|
// Garder seulement les valeurs non-vides et valides
|
||||||
|
if (value !== null && value !== undefined && value !== '') {
|
||||||
|
// Pour les dates, vérifier le format
|
||||||
|
if (key === 'birth_date' && value) {
|
||||||
|
// Vérifier que la date est dans un format valide
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (dateRegex.test(value)) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pour les codes postaux, vérifier que c'est un nombre
|
||||||
|
else if (key === 'birth_postal_code' && value) {
|
||||||
|
if (!isNaN(value) && value.toString().trim() !== '') {
|
||||||
|
cleaned[key] = parseInt(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pour les champs de paiement, toujours les inclure s'ils ont une valeur
|
||||||
|
else if (key.includes('payment') && value) {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
// Pour les autres champs, garder la valeur si elle n'est pas vide
|
||||||
|
else if (value.toString().trim() !== '') {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Préparer les données à sauvegarder avec nettoyage
|
||||||
|
const cleanedFormData = cleanDataForAutoSave(formData);
|
||||||
|
|
||||||
|
const dataToSave = {
|
||||||
|
student: cleanedFormData,
|
||||||
|
guardians: guardians.filter(
|
||||||
|
(guardian) =>
|
||||||
|
guardian &&
|
||||||
|
(guardian.first_name || guardian.last_name || guardian.email)
|
||||||
|
),
|
||||||
|
siblings: siblings.filter(
|
||||||
|
(sibling) => sibling && (sibling.first_name || sibling.last_name)
|
||||||
|
),
|
||||||
|
currentPage: currentPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utiliser la fonction d'auto-save dédiée
|
||||||
|
await autoSaveRegisterForm(studentId, dataToSave, csrfToken);
|
||||||
|
|
||||||
|
setLastSaved(new Date());
|
||||||
|
logger.debug('Auto-sauvegarde réussie');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Erreur lors de l'auto-sauvegarde:", error);
|
||||||
|
// Ne pas afficher d'erreur à l'utilisateur pour l'auto-save
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère la sauvegarde à chaque changement d'étape
|
||||||
|
*/
|
||||||
|
const saveStepData = async () => {
|
||||||
|
await autoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère la soumission d'un formulaire dynamique
|
||||||
|
*/
|
||||||
|
const handleDynamicFormSubmit = async (formData, templateId) => {
|
||||||
|
try {
|
||||||
|
logger.debug('Soumission du formulaire dynamique:', {
|
||||||
|
templateId,
|
||||||
|
formData,
|
||||||
|
csrfToken: !!csrfToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trouver le template correspondant pour récupérer sa configuration
|
||||||
|
const currentTemplate = schoolFileMasters.find(
|
||||||
|
(master) => master.id === templateId
|
||||||
|
);
|
||||||
|
if (!currentTemplate) {
|
||||||
|
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la structure complète avec la configuration et les réponses
|
||||||
|
const formTemplateData = {
|
||||||
|
id: currentTemplate.id,
|
||||||
|
title:
|
||||||
|
currentTemplate.formMasterData?.title ||
|
||||||
|
currentTemplate.title ||
|
||||||
|
currentTemplate.name ||
|
||||||
|
'Formulaire',
|
||||||
|
fields: (
|
||||||
|
currentTemplate.formMasterData?.fields ||
|
||||||
|
currentTemplate.fields ||
|
||||||
|
[]
|
||||||
|
).map((field) => ({
|
||||||
|
...field,
|
||||||
|
// Ajouter la réponse de l'utilisateur selon le type de champ
|
||||||
|
...(field.type === 'checkbox'
|
||||||
|
? { checked: formData[field.id] || false }
|
||||||
|
: {}),
|
||||||
|
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
||||||
|
...(field.type === 'text' ||
|
||||||
|
field.type === 'textarea' ||
|
||||||
|
field.type === 'email'
|
||||||
|
? { value: formData[field.id] || '' }
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
submitLabel: currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||||
|
responses: formData, // Garder aussi les réponses brutes pour facilité d'accès
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||||
|
logger.debug('Appel API saveFormResponses avec:', {
|
||||||
|
templateId,
|
||||||
|
formTemplateData,
|
||||||
|
});
|
||||||
|
const result = await saveFormResponses(
|
||||||
|
templateId,
|
||||||
|
formTemplateData,
|
||||||
|
csrfToken
|
||||||
|
);
|
||||||
|
logger.debug("Réponse de l'API:", result);
|
||||||
|
|
||||||
|
// Mettre à jour l'état local des réponses
|
||||||
|
setFormResponses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[templateId]: formData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mettre à jour l'état local pour indiquer que le formulaire est complété
|
||||||
|
setSchoolFileMasters((prevMasters) => {
|
||||||
|
return prevMasters.map((master) =>
|
||||||
|
master.id === templateId
|
||||||
|
? { ...master, completed: true, responses: formData }
|
||||||
|
: master
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Formulaire dynamique sauvegardé avec succès');
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la soumission du formulaire dynamique:', {
|
||||||
|
templateId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
// Afficher l'erreur à l'utilisateur
|
||||||
|
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère les changements de validation des formulaires dynamiques
|
||||||
|
*/
|
||||||
|
const handleDynamicFormsValidationChange = (isValid) => {
|
||||||
|
setIsPage5Valid(isValid);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -220,11 +389,69 @@ 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 school file masters
|
||||||
|
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
|
||||||
|
.then(async (data) => {
|
||||||
|
logger.debug('School file masters fetched:', data);
|
||||||
|
setSchoolFileMasters(data);
|
||||||
|
|
||||||
|
// Récupérer les données existantes de chaque template
|
||||||
|
const responsesMap = {};
|
||||||
|
for (const master of data) {
|
||||||
|
if (master.id) {
|
||||||
|
try {
|
||||||
|
const templateData = await fetchFormResponses(master.id);
|
||||||
|
if (templateData && templateData.formTemplateData) {
|
||||||
|
// Si on a les réponses brutes sauvegardées, les utiliser
|
||||||
|
if (templateData.formTemplateData.responses) {
|
||||||
|
responsesMap[master.id] =
|
||||||
|
templateData.formTemplateData.responses;
|
||||||
|
} else {
|
||||||
|
// Sinon, extraire les réponses depuis les champs
|
||||||
|
const responses = {};
|
||||||
|
if (templateData.formTemplateData.fields) {
|
||||||
|
templateData.formTemplateData.fields.forEach((field) => {
|
||||||
|
if (
|
||||||
|
field.type === 'checkbox' &&
|
||||||
|
field.checked !== undefined
|
||||||
|
) {
|
||||||
|
responses[field.id] = field.checked;
|
||||||
|
} else if (
|
||||||
|
field.type === 'radio' &&
|
||||||
|
field.selected !== undefined
|
||||||
|
) {
|
||||||
|
responses[field.id] = field.selected;
|
||||||
|
} else if (
|
||||||
|
(field.type === 'text' ||
|
||||||
|
field.type === 'textarea' ||
|
||||||
|
field.type === 'email') &&
|
||||||
|
field.value !== undefined
|
||||||
|
) {
|
||||||
|
responses[field.id] = field.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
responsesMap[master.id] = responses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`Pas de données existantes pour le template ${master.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Ce n'est pas critique si un template n'a pas de données
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormResponses(responsesMap);
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('Error fetching school file masters:', error)
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch data for registration payment modes
|
// Fetch data for registration payment modes
|
||||||
handleRegistrationPaymentModes();
|
handleRegistrationPaymentModes();
|
||||||
|
|
||||||
@ -385,7 +612,7 @@ export default function InscriptionFormShared({
|
|||||||
// Soumission du formulaire
|
// Soumission du formulaire
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Vérifier si le mode de paiement sélectionné est un prélèvement SEPA
|
// Vérifier si le mode de paiement sélectionné est un prélèvement SEPA
|
||||||
const isSepaPayment = formData.isSepa === 1;
|
const isSepaPayment = formData.isSepa === 1;
|
||||||
|
|
||||||
@ -425,11 +652,16 @@ export default function InscriptionFormShared({
|
|||||||
onSubmit(formDataToSend);
|
onSubmit(formDataToSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const handleNextPage = async () => {
|
||||||
|
// Sauvegarder avant de passer à l'étape suivante
|
||||||
|
await saveStepData();
|
||||||
|
setHasInteracted(false);
|
||||||
setCurrentPage(currentPage + 1);
|
setCurrentPage(currentPage + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviousPage = () => {
|
const handlePreviousPage = async () => {
|
||||||
|
// Sauvegarder avant de revenir à l'étape précédente
|
||||||
|
await saveStepData();
|
||||||
setCurrentPage(currentPage - 1);
|
setCurrentPage(currentPage - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -481,7 +713,18 @@ export default function InscriptionFormShared({
|
|||||||
setStep={setCurrentPage}
|
setStep={setCurrentPage}
|
||||||
isStepValid={isStepValid}
|
isStepValid={isStepValid}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 h-full mt-12 ">
|
|
||||||
|
{/* Indicateur de sauvegarde automatique */}
|
||||||
|
{enable && (
|
||||||
|
<AutoSaveIndicator
|
||||||
|
isSaving={isSaving}
|
||||||
|
lastSaved={lastSaved}
|
||||||
|
autoSaveEnabled={autoSaveEnabled}
|
||||||
|
onToggleAutoSave={() => setAutoSaveEnabled(!autoSaveEnabled)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 h-full mt-6">
|
||||||
{/* Page 1 : Informations sur l'élève */}
|
{/* Page 1 : Informations sur l'élève */}
|
||||||
{currentPage === 1 && (
|
{currentPage === 1 && (
|
||||||
<StudentInfoForm
|
<StudentInfoForm
|
||||||
@ -540,86 +783,15 @@ export default function InscriptionFormShared({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page 5 : Section Fichiers d'inscription */}
|
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||||
{currentPage === 5 && (
|
{currentPage === 5 && (
|
||||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
<DynamicFormsList
|
||||||
{/* Liste des états de signature */}
|
schoolFileMasters={schoolFileMasters}
|
||||||
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
existingResponses={formResponses}
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
onFormSubmit={handleDynamicFormSubmit}
|
||||||
Documents
|
onValidationChange={handleDynamicFormsValidationChange}
|
||||||
</h3>
|
enable={enable}
|
||||||
<ul className="space-y-2">
|
/>
|
||||||
{schoolFileTemplates.map((template, index) => (
|
|
||||||
<li
|
|
||||||
key={template.id}
|
|
||||||
className={`flex items-center cursor-pointer ${
|
|
||||||
index === currentTemplateIndex
|
|
||||||
? 'text-blue-600 font-bold'
|
|
||||||
: template.file !== null
|
|
||||||
? 'text-green-600'
|
|
||||||
: 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={() => setCurrentTemplateIndex(index)} // Mettre à jour l'index du template actuel
|
|
||||||
>
|
|
||||||
<span className="mr-2">
|
|
||||||
{template.file !== null ? (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Hourglass className="w-5 h-5 text-gray-600" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{template.name || 'Document sans nom'}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Affichage du fichier actuel */}
|
|
||||||
<div className="w-3/4">
|
|
||||||
{currentTemplateIndex < schoolFileTemplates.length && (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
|
||||||
{schoolFileTemplates[currentTemplateIndex].name ||
|
|
||||||
'Document sans nom'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
|
||||||
{schoolFileTemplates[currentTemplateIndex].description ||
|
|
||||||
'Aucune description disponible pour ce document.'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{schoolFileTemplates[currentTemplateIndex].file === null ? (
|
|
||||||
<DocusealForm
|
|
||||||
key={schoolFileTemplates[currentTemplateIndex].slug}
|
|
||||||
id="docusealForm"
|
|
||||||
src={`https://docuseal.com/s/${schoolFileTemplates[currentTemplateIndex].slug}`}
|
|
||||||
withDownloadButton={false}
|
|
||||||
withTitle={false}
|
|
||||||
onComplete={() =>
|
|
||||||
handleTemplateSigned(currentTemplateIndex)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<iframe
|
|
||||||
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
|
|
||||||
title="Document Viewer"
|
|
||||||
className="w-full"
|
|
||||||
style={{
|
|
||||||
height: '75vh',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message de fin */}
|
|
||||||
{currentTemplateIndex >= schoolFileTemplates.length && (
|
|
||||||
<div className="text-center text-green-600 font-semibold">
|
|
||||||
Tous les formulaires ont été signés avec succès !
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dernière page : Section Fichiers parents */}
|
{/* Dernière page : Section Fichiers parents */}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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' &&
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
85
Front-End/src/components/SectionHeaderDocument.js
Normal file
85
Front-End/src/components/SectionHeaderDocument.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
// Thèmes couleur selon le type
|
||||||
|
const THEME = {
|
||||||
|
groupe: {
|
||||||
|
bg: 'bg-blue-50',
|
||||||
|
border: 'border-blue-200',
|
||||||
|
iconBg: 'bg-blue-100',
|
||||||
|
icon: 'text-blue-600',
|
||||||
|
title: 'text-blue-800',
|
||||||
|
desc: 'text-blue-600',
|
||||||
|
button: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||||
|
buttonText: 'text-blue-700',
|
||||||
|
buttonHover: 'hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
formulaire: {
|
||||||
|
bg: 'bg-emerald-50',
|
||||||
|
border: 'border-emerald-200',
|
||||||
|
iconBg: 'bg-emerald-100',
|
||||||
|
icon: 'text-emerald-600',
|
||||||
|
title: 'text-emerald-800',
|
||||||
|
desc: 'text-emerald-600',
|
||||||
|
button: 'bg-emerald-500 text-white hover:bg-emerald-600',
|
||||||
|
buttonText: 'text-emerald-700',
|
||||||
|
buttonHover: 'hover:bg-emerald-100',
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
bg: 'bg-orange-50',
|
||||||
|
border: 'border-orange-200',
|
||||||
|
iconBg: 'bg-orange-100',
|
||||||
|
icon: 'text-orange-500',
|
||||||
|
title: 'text-orange-700',
|
||||||
|
desc: 'text-orange-600',
|
||||||
|
button: 'bg-orange-500 text-white hover:bg-orange-600',
|
||||||
|
buttonText: 'text-orange-700',
|
||||||
|
buttonHover: 'hover:bg-orange-100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionHeaderDocument = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
button = false,
|
||||||
|
buttonOpeningModal = false,
|
||||||
|
onClick = null,
|
||||||
|
className = '',
|
||||||
|
type = 'groupe', // 'groupe', 'formulaire', 'parent'
|
||||||
|
}) => {
|
||||||
|
const theme = THEME[type] || THEME.groupe;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between border-b ${theme.border} ${theme.bg} px-2 py-3 mb-4 ${className}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{Icon && (
|
||||||
|
<span className={`${theme.iconBg} p-2 rounded-md flex items-center justify-center`}>
|
||||||
|
<Icon className={`w-6 h-6 ${theme.icon}`} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-lg font-semibold ${theme.title}`}>{title}</h2>
|
||||||
|
{description && (
|
||||||
|
<p className={`text-xs ${theme.desc}`}>{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{button && onClick && (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
buttonOpeningModal
|
||||||
|
? `flex items-center ${theme.button} px-3 py-1 rounded-md shadow transition`
|
||||||
|
: `flex items-center ${theme.buttonText} ${theme.buttonHover} px-2 py-1 rounded-md`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5 mr-1" />
|
||||||
|
<span className="text-sm font-medium">Ajouter</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SectionHeaderDocument;
|
||||||
@ -1,69 +0,0 @@
|
|||||||
export default function SelectChoice({
|
|
||||||
type,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
required,
|
|
||||||
placeHolder,
|
|
||||||
choices,
|
|
||||||
callback,
|
|
||||||
selected,
|
|
||||||
errorMsg,
|
|
||||||
errorLocalMsg,
|
|
||||||
IconItem,
|
|
||||||
disabled = false,
|
|
||||||
}) {
|
|
||||||
const isPlaceholderSelected = selected === ''; // Vérifie si le placeholder est sélectionné
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor={name}
|
|
||||||
className="block text-sm font-medium text-gray-700"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className={`mt-1 flex items-center border rounded-md ${
|
|
||||||
errorMsg || errorLocalMsg
|
|
||||||
? 'border-red-500 hover:border-red-700'
|
|
||||||
: 'border-gray-200 hover:border-gray-400'
|
|
||||||
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
|
||||||
>
|
|
||||||
{IconItem && (
|
|
||||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
|
||||||
{<IconItem />}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<select
|
|
||||||
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
|
||||||
disabled ? 'bg-gray-100' : ''
|
|
||||||
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`} // Applique le style classique si une option autre que le placeholder est sélectionnée
|
|
||||||
type={type}
|
|
||||||
id={name}
|
|
||||||
name={name}
|
|
||||||
value={selected}
|
|
||||||
onChange={callback}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{/* Placeholder en italique */}
|
|
||||||
<option value="" className="italic text-gray-500">
|
|
||||||
{placeHolder?.toLowerCase()}
|
|
||||||
</option>
|
|
||||||
{/* Autres options sans italique */}
|
|
||||||
{choices.map(({ value, label }) => (
|
|
||||||
<option
|
|
||||||
key={value}
|
|
||||||
value={value}
|
|
||||||
className="not-italic text-gray-800"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user