feat: Précablage du formulaire dynamique [N3WTS-17]

This commit is contained in:
Luc SORIGNET
2025-11-30 17:24:25 +01:00
parent 7486f6c5ce
commit dd00cba385
41 changed files with 2637 additions and 606 deletions

View File

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

View File

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

View File

@ -1,5 +1,6 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14 # Generated by Django 5.1.3 on 2025-11-30 11:02
import Establishment.models
import django.contrib.postgres.fields 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)),
], ],
), ),
] ]

View File

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

View File

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

View File

@ -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')},
},
),
] ]

View File

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

View File

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

View File

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

View File

@ -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()}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14 # Generated by Django 5.1.3 on 2025-11-30 11:02
import Subscriptions.models import 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')),
], ],
), ),

View File

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

View File

@ -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

View File

@ -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}."

View File

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

View File

@ -35,6 +35,18 @@ def build_payload_from_request(request):
- supporte application/json ou form-data simple - supporte application/json ou form-data simple
Retour: (payload_dict, None) ou (None, Response erreur) 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 data_field = request.data.get('data') if hasattr(request.data, 'get') else None
if data_field: if data_field:
try: try:

View File

@ -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é.",

View File

@ -1,19 +1,18 @@
from django.http.response import JsonResponse from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
import json import json
from django.http import QueryDict from django.http import QueryDict
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import ( from Subscriptions.models import (
RegistrationForm, RegistrationForm,
RegistrationSchoolFileMaster, RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate, RegistrationParentFileMaster,
RegistrationParentFileMaster,
RegistrationParentFileTemplate RegistrationParentFileTemplate
) )
from N3wtSchool import bdd from N3wtSchool import bdd
@ -23,7 +22,8 @@ import Subscriptions.util as util
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RegistrationSchoolFileMasterView(APIView): class RegistrationSchoolFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser] parser_classes = [MultiPartParser, FormParser, JSONParser]
@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=[
@ -64,6 +64,7 @@ class RegistrationSchoolFileMasterView(APIView):
if resp: if resp:
return resp return resp
logger.info(f"payload for serializer: {payload}") logger.info(f"payload for serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True) serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
if serializer.is_valid(): if serializer.is_valid():
@ -90,6 +91,7 @@ class RegistrationSchoolFileMasterView(APIView):
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):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère un master de template d'inscription spécifique", operation_description="Récupère un master de template d'inscription spécifique",
responses={ responses={
@ -126,6 +128,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
if resp: if resp:
return resp return resp
logger.info(f"payload for update serializer: {payload}") logger.info(f"payload for update serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True) serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
if serializer.is_valid(): if serializer.is_valid():

View File

@ -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"])

View File

@ -15,7 +15,6 @@ export default function Home() {
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1> <h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
<p className="text-lg mb-8">{t('pleaseLogin')}</p> <p className="text-lg mb-8">{t('pleaseLogin')}</p>
<Button text={t('loginButton')} primary href="/users/login" /> <Button text={t('loginButton')} primary href="/users/login" />
<FormTemplateBuilder />
</div> </div>
); );
} }

View File

@ -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);
};

View File

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

View File

@ -6,6 +6,7 @@ import Button from './Button';
import IconSelector from './IconSelector'; import IconSelector from './IconSelector';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { FIELD_TYPES } from './FormTypes'; import { FIELD_TYPES } from './FormTypes';
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
export default function AddFieldModal({ export default function AddFieldModal({
isOpen, isOpen,
@ -55,6 +56,10 @@ export default function AddFieldModal({
acceptTypes: '', acceptTypes: '',
maxSize: 5, maxSize: 5,
checked: false, checked: false,
signatureData: '',
backgroundColor: '#ffffff',
penColor: '#000000',
penWidth: 2,
validation: { validation: {
pattern: '', pattern: '',
minLength: '', minLength: '',
@ -62,6 +67,16 @@ export default function AddFieldModal({
}, },
}; };
// 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); setCurrentField(defaultValues);
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer // Réinitialiser le formulaire avec les valeurs de l'élément à éditer
@ -75,17 +90,32 @@ export default function AddFieldModal({
acceptTypes: defaultValues.acceptTypes, acceptTypes: defaultValues.acceptTypes,
maxSize: defaultValues.maxSize, maxSize: defaultValues.maxSize,
checked: defaultValues.checked, checked: defaultValues.checked,
signatureData: defaultValues.signatureData,
backgroundColor: defaultValues.backgroundColor,
penColor: defaultValues.penColor,
penWidth: defaultValues.penWidth,
validation: defaultValues.validation, validation: defaultValues.validation,
}); });
} }
}, [isOpen, editingField, reset]); }, [isOpen, editingField, reset, isEditing]);
// Ajouter une option au select // Ajouter une option au select
const addOption = () => { 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()) { 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({ setCurrentField({
...currentField, ...currentField,
options: [...currentField.options, newOption.trim()], options: [...currentOptions, newOption.trim()],
}); });
setNewOption(''); setNewOption('');
} }
@ -93,7 +123,12 @@ export default function AddFieldModal({
// Supprimer une option du select // Supprimer une option du select
const removeOption = (index) => { const removeOption = (index) => {
const newOptions = currentField.options.filter((_, i) => i !== 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 }); setCurrentField({ ...currentField, options: newOptions });
}; };
@ -141,15 +176,28 @@ export default function AddFieldModal({
name="type" name="type"
selected={value} selected={value}
callback={(e) => { callback={(e) => {
onChange(e.target.value); 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({ setCurrentField({
...currentField, ...currentField,
type: e.target.value, type: newType,
options: updatedOptions,
}); });
}} }}
choices={FIELD_TYPES} choices={FIELD_TYPES_WITH_ICONS}
placeHolder="Sélectionner un type" placeHolder="Sélectionner un type"
required required
showIcons={true}
customSelect={true}
/> />
)} )}
/> />
@ -353,21 +401,22 @@ export default function AddFieldModal({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{currentField.options.map((option, index) => ( {Array.isArray(currentField.options) &&
<div currentField.options.map((option, index) => (
key={index} <div
className="flex items-center justify-between bg-gray-50 p-2 rounded" 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"
> >
<span>{option}</span>
</button> <button
</div> type="button"
))} onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
))}
</div> </div>
</div> </div>
)} )}
@ -396,21 +445,22 @@ export default function AddFieldModal({
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{currentField.options.map((option, index) => ( {Array.isArray(currentField.options) &&
<div currentField.options.map((option, index) => (
key={index} <div
className="flex items-center justify-between bg-gray-50 p-2 rounded" 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"
> >
<span>{option}</span>
</button> <button
</div> type="button"
))} onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
))}
</div> </div>
</div> </div>
)} )}
@ -484,6 +534,81 @@ export default function AddFieldModal({
</> </>
)} )}
{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' && ( {currentField.type === 'checkbox' && (
<> <>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import CheckBox from './CheckBox';
import ToggleSwitch from './ToggleSwitch'; import ToggleSwitch from './ToggleSwitch';
import InputPhone from './InputPhone'; import InputPhone from './InputPhone';
import FileUpload from './FileUpload'; import FileUpload from './FileUpload';
import SignatureField from './SignatureField';
/* /*
* Récupère une icône Lucide par son nom. * Récupère une icône Lucide par son nom.
@ -24,46 +25,8 @@ export function getIcon(name) {
} }
} }
const formConfigTest = {
id: 0,
title: 'Mon formulaire dynamique',
submitLabel: 'Envoyer',
fields: [
{ id: 'name', label: 'Nom', type: 'text', required: true },
{ id: 'email', label: 'Email', type: 'email' },
{
id: 'email2',
label: 'Email',
type: 'text',
icon: 'Mail',
},
{
id: 'role',
label: 'Rôle',
type: 'select',
options: ['Admin', 'Utilisateur', 'Invité'],
required: true,
},
{
type: 'paragraph',
text: "Bonjour, Bienvenue dans ce formulaire d'inscription haha",
},
{
id: 'birthdate',
label: 'Date de naissance',
type: 'date',
icon: 'Calendar',
},
{
id: 'textarea',
label: 'toto',
type: 'textarea',
},
],
};
export default function FormRenderer({ export default function FormRenderer({
formConfig = formConfigTest, formConfig,
csrfToken, csrfToken,
onFormSubmit = (data) => { onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2)); alert(JSON.stringify(data, null, 2));
@ -109,7 +72,8 @@ export default function FormRenderer({
const hasFiles = Object.keys(data).some((key) => { const hasFiles = Object.keys(data).some((key) => {
return ( return (
data[key] instanceof FileList || data[key] instanceof FileList ||
(data[key] && data[key][0] instanceof File) (data[key] && data[key][0] instanceof File) ||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
); );
}); });
@ -118,7 +82,7 @@ export default function FormRenderer({
const formData = new FormData(); const formData = new FormData();
// Ajouter l'ID du formulaire // Ajouter l'ID du formulaire
formData.append('formId', formConfig.id.toString()); formData.append('formId', (formConfig?.id || 'unknown').toString());
// Traiter chaque champ et ses valeurs // Traiter chaque champ et ses valeurs
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
@ -134,6 +98,29 @@ export default function FormRenderer({
formData.append(`files.${key}`, value[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 { } else {
// Gérer les autres types de champs // Gérer les autres types de champs
formData.append( formData.append(
@ -154,7 +141,7 @@ export default function FormRenderer({
} else { } else {
// Pas de fichier, on peut utiliser JSON // Pas de fichier, on peut utiliser JSON
const formattedData = { const formattedData = {
formId: formConfig.id, formId: formConfig?.id || 'unknown',
responses: { ...data }, responses: { ...data },
}; };
@ -189,10 +176,10 @@ export default function FormRenderer({
> >
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null} {csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
<h2 className="text-2xl font-bold text-center mb-4"> <h2 className="text-2xl font-bold text-center mb-4">
{formConfig.title} {formConfig?.title || 'Formulaire'}
</h2> </h2>
{formConfig.fields.map((field) => ( {(formConfig?.fields || []).map((field) => (
<div <div
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`} key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
className="flex flex-col mt-4" className="flex flex-col mt-4"
@ -428,13 +415,40 @@ export default function FormRenderer({
)} )}
/> />
)} )}
{field.type === 'signature' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<div>
<SignatureField
label={field.label}
required={field.required}
value={value || ''}
onChange={onChange}
backgroundColor={field.backgroundColor || '#ffffff'}
penColor={field.penColor || '#000000'}
penWidth={field.penWidth || 2}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
</div> </div>
))} ))}
<div className="form-group-submit mt-4"> <div className="form-group-submit mt-4">
<Button <Button
type="submit" type="submit"
primary primary
text={formConfig.submitLabel ? formConfig.submitLabel : 'Envoyer'} text={formConfig?.submitLabel || 'Envoyer'}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full" className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
/> />
</div> </div>

View File

@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
import InputTextIcon from './InputTextIcon'; import InputTextIcon from './InputTextIcon';
import FormRenderer from './FormRenderer'; import FormRenderer from './FormRenderer';
import AddFieldModal from './AddFieldModal'; import AddFieldModal from './AddFieldModal';
import FieldTypeSelector from './FieldTypeSelector';
import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { import {
@ -34,6 +35,7 @@ import {
ToggleLeft, ToggleLeft,
CheckSquare, CheckSquare,
FileUp, FileUp,
PenTool,
} from 'lucide-react'; } from 'lucide-react';
const FIELD_TYPES_ICON = { const FIELD_TYPES_ICON = {
@ -46,6 +48,7 @@ const FIELD_TYPES_ICON = {
checkbox: { icon: CheckSquare }, checkbox: { icon: CheckSquare },
toggle: { icon: ToggleLeft }, toggle: { icon: ToggleLeft },
file: { icon: FileUp }, file: { icon: FileUp },
signature: { icon: PenTool },
textarea: { icon: Type }, textarea: { icon: Type },
paragraph: { icon: AlignLeft }, paragraph: { icon: AlignLeft },
heading1: { icon: Heading1 }, heading1: { icon: Heading1 },
@ -168,15 +171,26 @@ const DraggableFieldItem = ({
); );
}; };
export default function FormTemplateBuilder() { export default function FormTemplateBuilder({
onSave,
initialData,
groups,
isEditing,
}) {
const [formConfig, setFormConfig] = useState({ const [formConfig, setFormConfig] = useState({
id: 0, id: initialData?.id || 0,
title: 'Nouveau formulaire', title: initialData?.name || 'Nouveau formulaire',
submitLabel: 'Envoyer', submitLabel: 'Envoyer',
fields: [], fields: initialData?.formMasterData?.fields || [],
}); });
const [selectedGroups, setSelectedGroups] = useState(
initialData?.groups?.map((g) => g.id) || []
);
const [showAddFieldModal, setShowAddFieldModal] = useState(false); const [showAddFieldModal, setShowAddFieldModal] = useState(false);
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
const [selectedFieldType, setSelectedFieldType] = useState(null);
const [editingIndex, setEditingIndex] = useState(-1); const [editingIndex, setEditingIndex] = useState(-1);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' }); const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
@ -185,6 +199,19 @@ export default function FormTemplateBuilder() {
const { reset: resetField } = useForm(); 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 // Gérer l'affichage du bouton de défilement
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
@ -235,7 +262,9 @@ export default function FormTemplateBuilder() {
? undefined ? undefined
: generateFieldId(data.label || 'field'), : generateFieldId(data.label || 'field'),
options: ['select', 'radio'].includes(data.type) options: ['select', 'radio'].includes(data.type)
? currentField.options ? Array.isArray(currentField.options)
? currentField.options
: []
: undefined, : undefined,
icon: data.icon || currentField.icon || undefined, icon: data.icon || currentField.icon || undefined,
placeholder: data.placeholder || undefined, placeholder: data.placeholder || undefined,
@ -345,35 +374,36 @@ export default function FormTemplateBuilder() {
return; return;
} }
if (selectedGroups.length === 0) {
setSaveMessage({
type: 'error',
text: "Sélectionnez au moins un groupe d'inscription",
});
return;
}
setSaving(true); setSaving(true);
setSaveMessage({ type: '', text: '' }); setSaveMessage({ type: '', text: '' });
try { try {
// Simulation d'envoi au backend (à remplacer par l'appel API réel) const dataToSave = {
// const response = await fetch('/api/form-templates', { name: formConfig.title,
// method: 'POST', group_ids: selectedGroups,
// headers: { formMasterData: formConfig,
// 'Content-Type': 'application/json', };
// },
// body: JSON.stringify(formConfig),
// });
// if (!response.ok) { if (isEditing && initialData) {
// throw new Error('Erreur lors de l\'enregistrement du formulaire'); dataToSave.id = initialData.id;
// } }
// const data = await response.json(); if (onSave) {
onSave(dataToSave);
// Simulation d'une réponse du backend }
await new Promise((resolve) => setTimeout(resolve, 1000));
setSaveMessage({ setSaveMessage({
type: 'success', type: 'success',
text: 'Formulaire enregistré avec succès', text: 'Formulaire enregistré avec succès',
}); });
// Si le backend renvoie un ID, on peut mettre à jour l'ID du formulaire
// setFormConfig({ ...formConfig, id: data.id });
} catch (error) { } catch (error) {
setSaveMessage({ setSaveMessage({
type: 'error', type: 'error',
@ -385,6 +415,13 @@ export default function FormTemplateBuilder() {
} }
}; };
// Fonction pour gérer la sélection d'un type de champ
const handleFieldTypeSelect = (fieldType) => {
setSelectedFieldType(fieldType);
setShowFieldTypeSelector(false);
setShowAddFieldModal(true);
};
return ( return (
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="max-w-6xl mx-auto p-6"> <div className="max-w-6xl mx-auto p-6">
@ -476,6 +513,44 @@ export default function FormTemplateBuilder() {
}) })
} }
/> />
{/* 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="space-y-2 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => (
<label key={group.id} className="flex items-center">
<input
type="checkbox"
checked={selectedGroups.includes(group.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedGroups([
...selectedGroups,
group.id,
]);
} else {
setSelectedGroups(
selectedGroups.filter((id) => id !== group.id)
);
}
}}
className="mr-2 text-blue-600"
/>
<span className="text-sm">{group.name}</span>
</label>
))
) : (
<p className="text-gray-500 text-sm">
Aucun groupe disponible
</p>
)}
</div>
</div>
</div> </div>
{/* Liste des champs */} {/* Liste des champs */}
@ -487,7 +562,8 @@ export default function FormTemplateBuilder() {
<button <button
onClick={() => { onClick={() => {
setEditingIndex(-1); setEditingIndex(-1);
setShowAddFieldModal(true); setSelectedFieldType(null);
setShowFieldTypeSelector(true);
}} }}
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors" className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
title="Ajouter un champ" title="Ajouter un champ"
@ -504,7 +580,8 @@ export default function FormTemplateBuilder() {
<button <button
onClick={() => { onClick={() => {
setEditingIndex(-1); setEditingIndex(-1);
setShowAddFieldModal(true); 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" className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
> >
@ -593,11 +670,22 @@ export default function FormTemplateBuilder() {
onClose={() => setShowAddFieldModal(false)} onClose={() => setShowAddFieldModal(false)}
onSubmit={handleFieldSubmit} onSubmit={handleFieldSubmit}
editingField={ editingField={
editingIndex >= 0 ? formConfig.fields[editingIndex] : null editingIndex >= 0
? formConfig.fields[editingIndex]
: selectedFieldType
? { type: selectedFieldType.value || selectedFieldType }
: null
} }
editingIndex={editingIndex} editingIndex={editingIndex}
/> />
{/* Sélecteur de type de champ */}
<FieldTypeSelector
isOpen={showFieldTypeSelector}
onClose={() => setShowFieldTypeSelector(false)}
onSelect={handleFieldTypeSelect}
/>
{/* Bouton flottant pour remonter en haut */} {/* Bouton flottant pour remonter en haut */}
{showScrollButton && ( {showScrollButton && (
<div className="fixed bottom-6 right-6 z-10"> <div className="fixed bottom-6 right-6 z-10">

View File

@ -8,6 +8,7 @@ export const FIELD_TYPES = [
{ value: 'checkbox', label: 'Case à cocher' }, { value: 'checkbox', label: 'Case à cocher' },
{ value: 'toggle', label: 'Interrupteur' }, { value: 'toggle', label: 'Interrupteur' },
{ value: 'file', label: 'Upload de fichier' }, { value: 'file', label: 'Upload de fichier' },
{ value: 'signature', label: 'Signature' },
{ value: 'textarea', label: 'Zone de texte riche' }, { value: 'textarea', label: 'Zone de texte riche' },
{ value: 'paragraph', label: 'Paragraphe' }, { value: 'paragraph', label: 'Paragraphe' },
{ value: 'heading1', label: 'Titre 1' }, { value: 'heading1', label: 'Titre 1' },

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

View File

@ -5,6 +5,10 @@ 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(() => {
@ -223,6 +392,66 @@ export default function InscriptionFormShared({
.catch((error) => logger.error('Error fetching profiles : ', error)); .catch((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();
@ -423,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);
}; };
@ -479,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
@ -538,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 */}

View File

@ -1,13 +1,20 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Download, Edit3, Trash2, FolderPlus, Signature, AlertTriangle } from 'lucide-react'; import {
Download,
Edit3,
Trash2,
FolderPlus,
FileText,
AlertTriangle,
} from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import Table from '@/components/Table'; import Table from '@/components/Table';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import { import {
// GET // GET
fetchRegistrationFileGroups, fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters, fetchRegistrationSchoolFileMasters,
fetchRegistrationSchoolFileTemplates,
fetchRegistrationParentFileMasters, fetchRegistrationParentFileMasters,
// POST // POST
createRegistrationFileGroup, createRegistrationFileGroup,
@ -20,7 +27,7 @@ import {
// DELETE // DELETE
deleteRegistrationFileGroup, deleteRegistrationFileGroup,
deleteRegistrationSchoolFileMaster, deleteRegistrationSchoolFileMaster,
deleteRegistrationParentFileMaster deleteRegistrationParentFileMaster,
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm'; import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -34,10 +41,9 @@ import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal'
export default function FilesGroupsManagement({ export default function FilesGroupsManagement({
csrfToken, csrfToken,
selectedEstablishmentId selectedEstablishmentId,
}) { }) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]); const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]); const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null); const [selectedGroup, setSelectedGroup] = useState(null);
@ -46,7 +52,6 @@ export default function FilesGroupsManagement({
const [fileToEdit, setFileToEdit] = useState(null); const [fileToEdit, setFileToEdit] = useState(null);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [groupToEdit, setGroupToEdit] = useState(null); const [groupToEdit, setGroupToEdit] = useState(null);
const [reloadTemplates, setReloadTemplates] = useState(false);
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
@ -54,10 +59,6 @@ export default function FilesGroupsManagement({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const handleReloadTemplates = () => {
setReloadTemplates(true);
};
const transformFileData = (file, groups) => { const transformFileData = (file, groups) => {
const groupInfos = file.groups.map( const groupInfos = file.groups.map(
(groupId) => (groupId) =>
@ -77,79 +78,67 @@ export default function FilesGroupsManagement({
Promise.all([ Promise.all([
fetchRegistrationSchoolFileMasters(selectedEstablishmentId), fetchRegistrationSchoolFileMasters(selectedEstablishmentId),
fetchRegistrationFileGroups(selectedEstablishmentId), fetchRegistrationFileGroups(selectedEstablishmentId),
fetchRegistrationSchoolFileTemplates(selectedEstablishmentId),
fetchRegistrationParentFileMasters(selectedEstablishmentId), fetchRegistrationParentFileMasters(selectedEstablishmentId),
]) ])
.then( .then(([dataSchoolFileMasters, groupsData, dataParentFileMasters]) => {
([ setGroups(groupsData);
dataSchoolFileMasters, setParentFileMasters(dataParentFileMasters);
groupsData, // Transformer chaque fichier pour inclure les informations complètes du groupe
dataSchoolFileTemplates, const transformedFiles = dataSchoolFileMasters.map((file) =>
dataParentFileMasters, transformFileData(file, groupsData)
]) => { );
setGroups(groupsData); setSchoolFileMasters(transformedFiles);
setSchoolFileTemplates(dataSchoolFileTemplates); })
setParentFileMasters(dataParentFileMasters);
// Transformer chaque fichier pour inclure les informations complètes du groupe
const transformedFiles = dataSchoolFileMasters.map((file) =>
transformFileData(file, groupsData)
);
setSchoolFileMasters(transformedFiles);
}
)
.catch((err) => { .catch((err) => {
logger.debug(err.message); logger.debug(err.message);
})
.finally(() => {
setReloadTemplates(false);
}); });
} }
}, [reloadTemplates, selectedEstablishmentId]); }, [selectedEstablishmentId]);
const deleteTemplateMaster = (templateMaster) => { const deleteTemplateMaster = (templateMaster) => {
setRemovePopupVisible(true); setRemovePopupVisible(true);
setRemovePopupMessage( setRemovePopupMessage(
`Attentions ! \nVous êtes sur le point de supprimer le document "${templateMaster.name}".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?` `Attention ! \nVous êtes sur le point de supprimer le formulaire "${templateMaster.name}".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
); );
setRemovePopupOnConfirm(() => () => { setRemovePopupOnConfirm(() => () => {
setIsLoading(true); setIsLoading(true);
// Supprimer le template master de la base de données
deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
.then((response) => {
if (response.ok) {
setSchoolFileMasters(
schoolFileMasters.filter(
(fichier) => fichier.id !== templateMaster.id
)
);
showNotification(
`Le document "${templateMaster.name}" a été correctement supprimé.`,
'success',
'Succès'
);
setRemovePopupVisible(false); // Supprimer le formulaire de la base de données
setIsLoading(false); deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
} else { .then((response) => {
if (response.ok) {
setSchoolFileMasters(
schoolFileMasters.filter(
(fichier) => fichier.id !== templateMaster.id
)
);
showNotification(
`Le formulaire "${templateMaster.name}" a été correctement supprimé.`,
'success',
'Succès'
);
setRemovePopupVisible(false);
} else {
showNotification(
`Erreur lors de la suppression du formulaire "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
}
})
.catch((error) => {
logger.error('Error deleting file from database:', error);
showNotification( showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`, `Erreur lors de la suppression du formulaire "${templateMaster.name}".`,
'error', 'error',
'Erreur' 'Erreur'
); );
setRemovePopupVisible(false); setRemovePopupVisible(false);
})
.finally(() => {
setIsLoading(false); setIsLoading(false);
} });
})
.catch((error) => {
logger.error('Error deleting file from database:', error);
showNotification(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
setIsLoading(false);
});
}); });
}; };
@ -159,12 +148,11 @@ export default function FilesGroupsManagement({
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleCreateTemplateMaster = ({ name, group_ids, id, is_required }) => { const handleCreateTemplateMaster = ({ name, group_ids, formMasterData }) => {
const data = { const data = {
name: name, name: name,
id: id,
groups: group_ids, groups: group_ids,
is_required: is_required, formMasterData: formMasterData, // Envoyer directement l'objet
}; };
logger.debug(data); logger.debug(data);
@ -174,18 +162,32 @@ export default function FilesGroupsManagement({
const transformedFile = transformFileData(data, groups); const transformedFile = transformFileData(data, groups);
setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]); setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]);
setIsModalOpen(false); setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été créé avec succès.`,
'success',
'Succès'
);
}) })
.catch((error) => { .catch((error) => {
logger.error('Error uploading file:', error); logger.error('Error creating form:', error);
showNotification(
'Erreur lors de la création du formulaire',
'error',
'Erreur'
);
}); });
}; };
const handleEditTemplateMaster = ({ name, group_ids, id, is_required }) => { const handleEditTemplateMaster = ({
name,
group_ids,
formMasterData,
id,
}) => {
const data = { const data = {
name: name, name: name,
id: id,
groups: group_ids, groups: group_ids,
is_required: is_required, formMasterData: formMasterData,
}; };
logger.debug(data); logger.debug(data);
@ -197,11 +199,16 @@ export default function FilesGroupsManagement({
prevFichiers.map((f) => (f.id === id ? transformedFile : f)) prevFichiers.map((f) => (f.id === id ? transformedFile : f))
); );
setIsModalOpen(false); setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été modifié avec succès.`,
'success',
'Succès'
);
}) })
.catch((error) => { .catch((error) => {
logger.error('Error editing file:', error); logger.error('Error editing form:', error);
showNotification( showNotification(
'Erreur lors de la modification du fichier', 'Erreur lors de la modification du formulaire',
'error', 'error',
'Erreur' 'Erreur'
); );
@ -383,24 +390,17 @@ export default function FilesGroupsManagement({
name: 'Actions', name: 'Actions',
transform: (row) => ( transform: (row) => (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{row.file && (
<a
href={`${BASE_URL}${row.file}`}
target="_blank"
className="text-blue-500 hover:text-blue-700"
>
<Download className="w-5 h-5" />
</a>
)}
<button <button
onClick={() => editTemplateMaster(row)} onClick={() => editTemplateMaster(row)}
className="text-blue-500 hover:text-blue-700" className="text-blue-500 hover:text-blue-700"
title="Modifier le formulaire"
> >
<Edit3 className="w-5 h-5" /> <Edit3 className="w-5 h-5" />
</button> </button>
<button <button
onClick={() => deleteTemplateMaster(row)} onClick={() => deleteTemplateMaster(row)}
className="text-red-500 hover:text-red-700" className="text-red-500 hover:text-red-700"
title="Supprimer le formulaire"
> >
<Trash2 className="w-5 h-5" /> <Trash2 className="w-5 h-5" />
</button> </button>
@ -439,23 +439,26 @@ export default function FilesGroupsManagement({
return ( return (
<div className="w-full"> <div className="w-full">
{/* Modal pour les fichiers */} {/* Modal pour les formulaires */}
<Modal <Modal
isOpen={isModalOpen} isOpen={isModalOpen}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
setIsModalOpen(isOpen); setIsModalOpen(isOpen);
if (!isOpen) { if (!isOpen) {
setFileToEdit(null); setFileToEdit(null);
setIsEditing(false);
} }
}} }}
title={isEditing ? 'Modification du document' : 'Ajouter un document'} title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire'}
modalClassName="w-4/5 h-4/5" modalClassName="w-11/12 h-5/6"
> >
<FileUploadDocuSeal <FormTemplateBuilder
handleCreateTemplateMaster={handleCreateTemplateMaster} onSave={
handleEditTemplateMaster={handleEditTemplateMaster} isEditing ? handleEditTemplateMaster : handleCreateTemplateMaster
fileToEdit={fileToEdit} }
onSuccess={handleReloadTemplates} initialData={fileToEdit}
groups={groups}
isEditing={isEditing}
/> />
</Modal> </Modal>
@ -496,17 +499,18 @@ export default function FilesGroupsManagement({
/> />
</div> </div>
{/* Section Fichiers */} {/* Section Formulaires */}
<div className="mt-12 mb-4 w-3/5"> <div className="mt-12 mb-4 w-3/5">
<SectionHeader <SectionHeader
icon={Signature} icon={FileText}
title="Formulaires à remplir" title="Formulaires personnalisés"
description="Gérez les formulaires nécessitant une signature électronique." description="Créez et gérez vos formulaires d'inscription personnalisés."
button={true} button={true}
buttonOpeningModal={true} buttonOpeningModal={true}
onClick={() => { onClick={() => {
setIsModalOpen(true); setIsModalOpen(true);
setIsEditing(false); setIsEditing(false);
setFileToEdit(null);
}} }}
/> />
<Table <Table
@ -516,7 +520,7 @@ export default function FilesGroupsManagement({
<AlertMessage <AlertMessage
type="warning" type="warning"
title="Aucun formulaire enregistré" title="Aucun formulaire enregistré"
message="Veuillez procéder à la création d'un nouveau formulaire à signer" message="Veuillez procéder à la création d'un nouveau formulaire d'inscription"
/> />
} }
/> />

View File

@ -1,6 +1,8 @@
TZ="Europe/Paris" TZ="Europe/Paris"
TEST_MODE=true TEST_MODE=true
FLUSH_DATA=false
MIGRATE_DATA=false
CSRF_COOKIE_SECURE=true CSRF_COOKIE_SECURE=true
CSRF_COOKIE_DOMAIN=".localhost" CSRF_COOKIE_DOMAIN=".localhost"
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080 CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080