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.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
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
from django.db import migrations, models
@ -24,6 +25,7 @@ class Migration(migrations.Migration):
('licence_code', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
],
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,9 +17,12 @@ def getConnection(id_establishement):
try:
# Récupérer l'instance de l'établissement
establishment = Establishment.objects.get(id=id_establishement)
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
try:
# Récupérer les paramètres SMTP associés à l'établissement
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
# Créer une connexion SMTP avec les paramètres récupérés
connection = get_connection(
@ -32,9 +35,11 @@ def getConnection(id_establishement):
)
return connection
except SMTPSettings.DoesNotExist:
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist:
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
@ -53,11 +58,13 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
plain_message = strip_tags(message)
if connection is not None:
from_email = username
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
else:
from_email = settings.EMAIL_HOST_USER
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
logger.info(f"From email: {from_email}")
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
email = EmailMultiAlternatives(
subject=subject,
@ -79,6 +86,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
logger.error(f"Settings : {connection}")
logger.error(f"Settings : {connection}")
logger.error(f"Type d'erreur: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
from rest_framework import serializers
from .models import (
RegistrationFileGroup,
RegistrationForm,
Student,
Guardian,
Sibling,
RegistrationFileGroup,
RegistrationForm,
Student,
Guardian,
Sibling,
Language,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
AbsenceManagement,
BilanCompetence
)
@ -95,7 +95,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = RegistrationForm
fields = ['student_id', 'last_name', 'first_name', 'guardians']
def get_last_name(self, obj):
return obj.student.last_name
@ -164,12 +164,20 @@ class StudentSerializer(serializers.ModelSerializer):
if guardian_id:
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
guardian_instance, created = Guardian.objects.update_or_create(
id=guardian_id,
defaults=guardian_data
)
guardians_ids.append(guardian_instance.id)
continue
try:
guardian_instance = Guardian.objects.get(id=guardian_id)
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
for field, value in guardian_data.items():
if field != 'id': # Ne pas mettre à jour l'ID
setattr(guardian_instance, field, value)
guardian_instance.save()
guardians_ids.append(guardian_instance.id)
continue
except Guardian.DoesNotExist:
# Si le guardian n'existe pas, créer un nouveau
guardian_instance = Guardian.objects.create(**guardian_data)
guardians_ids.append(guardian_instance.id)
continue
if profile_role_data:
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil

View File

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

View File

@ -0,0 +1,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
Retour: (payload_dict, None) ou (None, Response erreur)
"""
# Si c'est du JSON pur (Content-Type: application/json)
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
try:
# request.data contient déjà le JSON parsé par Django REST
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
logger.info(f"JSON payload extracted: {payload}")
return payload, None
except Exception as e:
logger.error(f'Error processing JSON: {e}')
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
# Cas multipart/form-data avec champ 'data'
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
if data_field:
try:

View File

@ -17,10 +17,10 @@ import Subscriptions.util as util
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.pagination import CustomSubscriptionPagination
from Subscriptions.models import (
Guardian,
RegistrationForm,
RegistrationSchoolFileTemplate,
RegistrationFileGroup,
Guardian,
RegistrationForm,
RegistrationSchoolFileTemplate,
RegistrationFileGroup,
RegistrationParentFileTemplate,
StudentCompetency
)
@ -431,6 +431,262 @@ class RegisterFormWithIdView(APIView):
# Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False)
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
}
),
responses={200: RegistrationFormSerializer()},
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
operation_summary="Auto-sauvegarder un dossier d'inscription"
)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
def patch(self, request, id):
"""
Auto-sauvegarde partielle d'un dossier d'inscription.
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
"""
try:
# Récupérer le dossier d'inscription
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
if not registerForm:
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Préparer les données à mettre à jour
update_data = {}
# Traiter les données étudiant si présentes
if 'student_data' in request.data:
try:
student_data = json.loads(request.data['student_data'])
# Extraire les données de paiement des données étudiant
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
payment_data = {}
for field in payment_fields:
if field in student_data:
payment_data[field] = student_data.pop(field)
# Si nous avons des données de paiement, les traiter
if payment_data:
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
# Traiter les données de paiement
payment_updates = {}
# Gestion du mode de paiement d'inscription
if 'registration_payment' in payment_data and payment_data['registration_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
registerForm.registration_payment = payment_mode
payment_updates['registration_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
# Gestion du mode de paiement de scolarité
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
registerForm.tuition_payment = payment_mode
payment_updates['tuition_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
# Gestion du plan de paiement d'inscription
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
registerForm.registration_payment_plan = payment_plan
payment_updates['registration_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
# Gestion du plan de paiement de scolarité
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
registerForm.tuition_payment_plan = payment_plan
payment_updates['tuition_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
# Sauvegarder les modifications de paiement
if payment_updates:
registerForm.save()
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
update_data['student'] = student_data
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in student_data")
# Traiter les données des responsables si présentes
if 'guardians_data' in request.data:
try:
guardians_data = json.loads(request.data['guardians_data'])
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
# Enregistrer directement chaque guardian avec le modèle
for i, guardian_data in enumerate(guardians_data):
guardian_id = guardian_data.get('id')
if guardian_id:
try:
# Récupérer le guardian existant et mettre à jour ses champs
guardian = Guardian.objects.get(id=guardian_id)
# Mettre à jour les champs si ils sont présents
if 'birth_date' in guardian_data and guardian_data['birth_date']:
guardian.birth_date = guardian_data['birth_date']
if 'profession' in guardian_data:
guardian.profession = guardian_data['profession']
if 'address' in guardian_data:
guardian.address = guardian_data['address']
if 'phone' in guardian_data:
guardian.phone = guardian_data['phone']
if 'first_name' in guardian_data:
guardian.first_name = guardian_data['first_name']
if 'last_name' in guardian_data:
guardian.last_name = guardian_data['last_name']
guardian.save()
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
except Guardian.DoesNotExist:
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in guardians_data")
# Traiter les données de la fratrie si présentes
if 'siblings_data' in request.data:
try:
siblings_data = json.loads(request.data['siblings_data'])
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
# Enregistrer directement chaque sibling avec le modèle
for i, sibling_data in enumerate(siblings_data):
sibling_id = sibling_data.get('id')
if sibling_id:
try:
# Récupérer le sibling existant et mettre à jour ses champs
from Subscriptions.models import Sibling
sibling = Sibling.objects.get(id=sibling_id)
# Mettre à jour les champs si ils sont présents
if 'first_name' in sibling_data:
sibling.first_name = sibling_data['first_name']
if 'last_name' in sibling_data:
sibling.last_name = sibling_data['last_name']
if 'birth_date' in sibling_data and sibling_data['birth_date']:
sibling.birth_date = sibling_data['birth_date']
sibling.save()
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
except Sibling.DoesNotExist:
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in siblings_data")
# Traiter les données de paiement si présentes
if 'payment_data' in request.data:
try:
payment_data = json.loads(request.data['payment_data'])
logger.debug(f"Auto-save: payment_data = {payment_data}")
# Mettre à jour directement les champs de paiement du formulaire
payment_updates = {}
# Gestion du mode de paiement d'inscription
if 'registration_payment' in payment_data and payment_data['registration_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
registerForm.registration_payment = payment_mode
payment_updates['registration_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
# Gestion du mode de paiement de scolarité
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
try:
from School.models import PaymentMode
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
registerForm.tuition_payment = payment_mode
payment_updates['tuition_payment'] = payment_mode.id
except PaymentMode.DoesNotExist:
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
# Gestion du plan de paiement d'inscription
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
registerForm.registration_payment_plan = payment_plan
payment_updates['registration_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
# Gestion du plan de paiement de scolarité
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
try:
from School.models import PaymentPlan
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
registerForm.tuition_payment_plan = payment_plan
payment_updates['tuition_payment_plan'] = payment_plan.id
except PaymentPlan.DoesNotExist:
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
# Sauvegarder les modifications de paiement
if payment_updates:
registerForm.save()
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
except json.JSONDecodeError:
logger.warning("Auto-save: Invalid JSON in payment_data")
# Mettre à jour la page actuelle si présente
if 'current_page' in request.data:
try:
current_page = int(request.data['current_page'])
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
logger.debug(f"Auto-save: current_page = {current_page}")
except (ValueError, TypeError):
logger.warning("Auto-save: Invalid current_page value")
# Effectuer la mise à jour partielle seulement si nous avons des données
if update_data:
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
if serializer.is_valid():
serializer.save()
logger.debug(f"Auto-save successful for student {id}")
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
else:
logger.warning(f"Auto-save validation errors: {serializer.errors}")
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
else:
# Pas de données à sauvegarder, mais on retourne un succès
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
except Exception as e:
logger.error(f"Auto-save error for student {id}: {str(e)}")
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
@swagger_auto_schema(
responses={204: 'No Content'},
operation_description="Supprime un dossier d'inscription donné.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import Button from './Button';
import IconSelector from './IconSelector';
import * as LucideIcons from 'lucide-react';
import { FIELD_TYPES } from './FormTypes';
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
export default function AddFieldModal({
isOpen,
@ -55,6 +56,10 @@ export default function AddFieldModal({
acceptTypes: '',
maxSize: 5,
checked: false,
signatureData: '',
backgroundColor: '#ffffff',
penColor: '#000000',
penWidth: 2,
validation: {
pattern: '',
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);
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
@ -75,17 +90,32 @@ export default function AddFieldModal({
acceptTypes: defaultValues.acceptTypes,
maxSize: defaultValues.maxSize,
checked: defaultValues.checked,
signatureData: defaultValues.signatureData,
backgroundColor: defaultValues.backgroundColor,
penColor: defaultValues.penColor,
penWidth: defaultValues.penWidth,
validation: defaultValues.validation,
});
}
}, [isOpen, editingField, reset]);
}, [isOpen, editingField, reset, isEditing]);
// 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()) {
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
const currentOptions = Array.isArray(currentField.options)
? currentField.options
: [];
setCurrentField({
...currentField,
options: [...currentField.options, newOption.trim()],
options: [...currentOptions, newOption.trim()],
});
setNewOption('');
}
@ -93,7 +123,12 @@ export default function AddFieldModal({
// Supprimer une option du select
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 });
};
@ -141,15 +176,28 @@ export default function AddFieldModal({
name="type"
selected={value}
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({
...currentField,
type: e.target.value,
type: newType,
options: updatedOptions,
});
}}
choices={FIELD_TYPES}
choices={FIELD_TYPES_WITH_ICONS}
placeHolder="Sélectionner un type"
required
showIcons={true}
customSelect={true}
/>
)}
/>
@ -353,21 +401,22 @@ export default function AddFieldModal({
/>
</div>
<div className="space-y-1">
{currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
</button>
</div>
))}
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
))}
</div>
</div>
)}
@ -396,21 +445,22 @@ export default function AddFieldModal({
/>
</div>
<div className="space-y-1">
{currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
>
</button>
</div>
))}
<span>{option}</span>
<button
type="button"
onClick={() => removeOption(index)}
className="text-red-500 hover:text-red-700"
>
</button>
</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' && (
<>
<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 InputPhone from './InputPhone';
import FileUpload from './FileUpload';
import SignatureField from './SignatureField';
/*
* 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({
formConfig = formConfigTest,
formConfig,
csrfToken,
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
@ -109,7 +72,8 @@ export default function FormRenderer({
const hasFiles = Object.keys(data).some((key) => {
return (
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();
// Ajouter l'ID du formulaire
formData.append('formId', formConfig.id.toString());
formData.append('formId', (formConfig?.id || 'unknown').toString());
// Traiter chaque champ et ses valeurs
Object.keys(data).forEach((key) => {
@ -134,6 +98,29 @@ export default function FormRenderer({
formData.append(`files.${key}`, value[i]);
}
}
} else if (
typeof value === 'string' &&
value.startsWith('data:image')
) {
// Gérer les signatures (SVG ou images base64)
if (value.includes('svg+xml')) {
// Gérer les signatures SVG
const svgData = value.split(',')[1];
const svgBlob = new Blob([atob(svgData)], {
type: 'image/svg+xml',
});
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
} else {
// Gérer les images base64 classiques
const byteString = atob(value.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: 'image/png' });
formData.append(`files.${key}`, blob, `signature_${key}.png`);
}
} else {
// Gérer les autres types de champs
formData.append(
@ -154,7 +141,7 @@ export default function FormRenderer({
} else {
// Pas de fichier, on peut utiliser JSON
const formattedData = {
formId: formConfig.id,
formId: formConfig?.id || 'unknown',
responses: { ...data },
};
@ -189,10 +176,10 @@ export default function FormRenderer({
>
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
<h2 className="text-2xl font-bold text-center mb-4">
{formConfig.title}
{formConfig?.title || 'Formulaire'}
</h2>
{formConfig.fields.map((field) => (
{(formConfig?.fields || []).map((field) => (
<div
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
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 className="form-group-submit mt-4">
<Button
type="submit"
primary
text={formConfig.submitLabel ? formConfig.submitLabel : 'Envoyer'}
text={formConfig?.submitLabel || 'Envoyer'}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
fetchRegistrationSchoolFileMasters,
saveFormResponses,
fetchFormResponses,
autoSaveRegisterForm,
} from '@/app/actions/subscriptionAction';
import {
downloadTemplate,
@ -21,7 +25,8 @@ import { fetchProfiles } from '@/app/actions/authAction';
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
import logger from '@/utils/logger';
import FilesToUpload from '@/components/Inscription/FilesToUpload';
import { DocusealForm } from '@docuseal/react';
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
import AutoSaveIndicator from '@/components/AutoSaveIndicator';
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
@ -75,6 +80,8 @@ export default function InscriptionFormShared({
const [uploadedFiles, setUploadedFiles] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [formResponses, setFormResponses] = useState({});
const [currentPage, setCurrentPage] = useState(1);
const [isPage1Valid, setIsPage1Valid] = useState(false);
@ -90,6 +97,9 @@ export default function InscriptionFormShared({
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [profiles, setProfiles] = useState([]);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState(null);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const router = useRouter();
@ -121,18 +131,18 @@ export default function InscriptionFormShared({
}, [schoolFileTemplates]);
useEffect(() => {
// Vérifier si tous les templates ont leur champ "file" différent de null
const allSigned = schoolFileTemplates.every(
(template) => template.file !== null
);
// Vérifier si tous les formulaires maîtres sont complétés
const allCompleted =
schoolFileMasters.length === 0 ||
schoolFileMasters.every((master) => master.completed === true);
// Mettre à jour isPage4Valid en fonction de cette condition
setIsPage5Valid(allSigned);
// Mettre à jour isPage5Valid en fonction de cette condition
setIsPage5Valid(allCompleted);
if (allSigned) {
if (allCompleted) {
setCurrentTemplateIndex(0);
}
}, [schoolFileTemplates]);
}, [schoolFileMasters]);
useEffect(() => {
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
@ -145,57 +155,216 @@ export default function InscriptionFormShared({
logger.debug(allRequiredUploaded);
}, [parentFileTemplates]);
const handleTemplateSigned = (index) => {
const template = schoolFileTemplates[index];
// Auto-sauvegarde périodique (toutes les 30 secondes)
useEffect(() => {
if (!enable || !autoSaveEnabled) return;
if (!template) {
logger.error("Template introuvable pour l'index donné.");
const interval = setInterval(() => {
autoSave();
}, 30000); // 30 secondes
return () => clearInterval(interval);
}, [enable, autoSaveEnabled, formData, guardians, siblings]);
// Auto-sauvegarde quand les données changent (avec debounce)
useEffect(() => {
if (!enable || !autoSaveEnabled) return;
const timeout = setTimeout(() => {
autoSave();
}, 2000); // Attendre 2 secondes après le dernier changement
return () => clearTimeout(timeout);
}, [formData, guardians, siblings]);
/**
* Fonction d'auto-sauvegarde qui sauvegarde les données en cours
*/
const autoSave = async () => {
if (!autoSaveEnabled || !studentId || isSaving) {
return;
}
// Télécharger le template
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
.then((downloadUrl) => fetch(downloadUrl))
.then((response) => {
if (!response.ok) {
throw new Error('Erreur lors du téléchargement du fichier.');
}
return response.blob();
})
.then((blob) => {
const file = new File([blob], `${template.name}.pdf`, {
type: blob.type,
});
// Préparer les données pour la mise à jour
const updateData = new FormData();
updateData.append('file', file);
// Mettre à jour le template via l'API
return editRegistrationSchoolFileTemplates(
template.id,
updateData,
csrfToken
);
})
.then((updatedTemplate) => {
logger.debug('Template mis à jour avec succès :', updatedTemplate);
// Mettre à jour l'état local de schoolFileTemplates
setSchoolFileTemplates((prevTemplates) => {
const updatedTemplates = prevTemplates.map((t, i) =>
i === index ? { ...t, file: updatedTemplate.data.file } : t
);
logger.debug(
'État schoolFileTemplates mis à jour :',
updatedTemplates
);
return updatedTemplates;
});
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du template :', error);
try {
setIsSaving(true);
logger.debug('Auto-sauvegarde en cours...', {
studentId,
formDataKeys: Object.keys(formData),
paymentFields: {
registration_payment: formData.registration_payment,
registration_payment_plan: formData.registration_payment_plan,
tuition_payment: formData.tuition_payment,
tuition_payment_plan: formData.tuition_payment_plan,
},
guardians: guardians.length,
siblings: siblings.length,
currentPage,
});
// Fonction helper pour nettoyer les données avant sauvegarde
const cleanDataForAutoSave = (data) => {
const cleaned = {};
Object.keys(data).forEach((key) => {
const value = data[key];
// Garder seulement les valeurs non-vides et valides
if (value !== null && value !== undefined && value !== '') {
// Pour les dates, vérifier le format
if (key === 'birth_date' && value) {
// Vérifier que la date est dans un format valide
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (dateRegex.test(value)) {
cleaned[key] = value;
}
}
// Pour les codes postaux, vérifier que c'est un nombre
else if (key === 'birth_postal_code' && value) {
if (!isNaN(value) && value.toString().trim() !== '') {
cleaned[key] = parseInt(value);
}
}
// Pour les champs de paiement, toujours les inclure s'ils ont une valeur
else if (key.includes('payment') && value) {
cleaned[key] = value;
}
// Pour les autres champs, garder la valeur si elle n'est pas vide
else if (value.toString().trim() !== '') {
cleaned[key] = value;
}
}
});
return cleaned;
};
// Préparer les données à sauvegarder avec nettoyage
const cleanedFormData = cleanDataForAutoSave(formData);
const dataToSave = {
student: cleanedFormData,
guardians: guardians.filter(
(guardian) =>
guardian &&
(guardian.first_name || guardian.last_name || guardian.email)
),
siblings: siblings.filter(
(sibling) => sibling && (sibling.first_name || sibling.last_name)
),
currentPage: currentPage,
};
// Utiliser la fonction d'auto-save dédiée
await autoSaveRegisterForm(studentId, dataToSave, csrfToken);
setLastSaved(new Date());
logger.debug('Auto-sauvegarde réussie');
} catch (error) {
logger.error("Erreur lors de l'auto-sauvegarde:", error);
// Ne pas afficher d'erreur à l'utilisateur pour l'auto-save
} finally {
setIsSaving(false);
}
};
/**
* Gère la sauvegarde à chaque changement d'étape
*/
const saveStepData = async () => {
await autoSave();
};
/**
* Gère la soumission d'un formulaire dynamique
*/
const handleDynamicFormSubmit = async (formData, templateId) => {
try {
logger.debug('Soumission du formulaire dynamique:', {
templateId,
formData,
csrfToken: !!csrfToken,
});
// Trouver le template correspondant pour récupérer sa configuration
const currentTemplate = 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(() => {
@ -223,6 +392,66 @@ export default function InscriptionFormShared({
.catch((error) => logger.error('Error fetching profiles : ', error));
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
handleRegistrationPaymentModes();
@ -423,11 +652,16 @@ export default function InscriptionFormShared({
onSubmit(formDataToSend);
};
const handleNextPage = () => {
const handleNextPage = async () => {
// Sauvegarder avant de passer à l'étape suivante
await saveStepData();
setHasInteracted(false);
setCurrentPage(currentPage + 1);
};
const handlePreviousPage = () => {
const handlePreviousPage = async () => {
// Sauvegarder avant de revenir à l'étape précédente
await saveStepData();
setCurrentPage(currentPage - 1);
};
@ -479,7 +713,18 @@ export default function InscriptionFormShared({
setStep={setCurrentPage}
isStepValid={isStepValid}
/>
<div className="flex-1 h-full mt-12 ">
{/* Indicateur de sauvegarde automatique */}
{enable && (
<AutoSaveIndicator
isSaving={isSaving}
lastSaved={lastSaved}
autoSaveEnabled={autoSaveEnabled}
onToggleAutoSave={() => setAutoSaveEnabled(!autoSaveEnabled)}
/>
)}
<div className="flex-1 h-full mt-6">
{/* Page 1 : Informations sur l'élève */}
{currentPage === 1 && (
<StudentInfoForm
@ -538,86 +783,15 @@ export default function InscriptionFormShared({
</>
)}
{/* Page 5 : Section Fichiers d'inscription */}
{/* Page 5 : Formulaires dynamiques d'inscription */}
{currentPage === 5 && (
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
{/* Liste des états de signature */}
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Documents
</h3>
<ul className="space-y-2">
{schoolFileTemplates.map((template, index) => (
<li
key={template.id}
className={`flex items-center cursor-pointer ${
index === currentTemplateIndex
? 'text-blue-600 font-bold'
: template.file !== null
? 'text-green-600'
: 'text-gray-600'
}`}
onClick={() => setCurrentTemplateIndex(index)} // Mettre à jour l'index du template actuel
>
<span className="mr-2">
{template.file !== null ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<Hourglass className="w-5 h-5 text-gray-600" />
)}
</span>
{template.name || 'Document sans nom'}
</li>
))}
</ul>
</div>
{/* Affichage du fichier actuel */}
<div className="w-3/4">
{currentTemplateIndex < schoolFileTemplates.length && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
{schoolFileTemplates[currentTemplateIndex].name ||
'Document sans nom'}
</h3>
<p className="text-sm text-gray-500 mb-4">
{schoolFileTemplates[currentTemplateIndex].description ||
'Aucune description disponible pour ce document.'}
</p>
{schoolFileTemplates[currentTemplateIndex].file === null ? (
<DocusealForm
key={schoolFileTemplates[currentTemplateIndex].slug}
id="docusealForm"
src={`https://docuseal.com/s/${schoolFileTemplates[currentTemplateIndex].slug}`}
withDownloadButton={false}
withTitle={false}
onComplete={() =>
handleTemplateSigned(currentTemplateIndex)
}
/>
) : (
<iframe
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
title="Document Viewer"
className="w-full"
style={{
height: '75vh',
border: 'none',
}}
/>
)}
</div>
)}
{/* Message de fin */}
{currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center text-green-600 font-semibold">
Tous les formulaires ont été signés avec succès !
</div>
)}
</div>
</div>
<DynamicFormsList
schoolFileMasters={schoolFileMasters}
existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange}
enable={enable}
/>
)}
{/* Dernière page : Section Fichiers parents */}

View File

@ -1,13 +1,20 @@
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 Table from '@/components/Table';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import { BASE_URL } from '@/utils/Url';
import {
// GET
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters,
fetchRegistrationSchoolFileTemplates,
fetchRegistrationParentFileMasters,
// POST
createRegistrationFileGroup,
@ -20,7 +27,7 @@ import {
// DELETE
deleteRegistrationFileGroup,
deleteRegistrationSchoolFileMaster,
deleteRegistrationParentFileMaster
deleteRegistrationParentFileMaster,
} from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger';
@ -34,10 +41,9 @@ import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal'
export default function FilesGroupsManagement({
csrfToken,
selectedEstablishmentId
selectedEstablishmentId,
}) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
@ -46,7 +52,6 @@ export default function FilesGroupsManagement({
const [fileToEdit, setFileToEdit] = useState(null);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [groupToEdit, setGroupToEdit] = useState(null);
const [reloadTemplates, setReloadTemplates] = useState(false);
const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState('');
@ -54,10 +59,6 @@ export default function FilesGroupsManagement({
const [isLoading, setIsLoading] = useState(false);
const { showNotification } = useNotification();
const handleReloadTemplates = () => {
setReloadTemplates(true);
};
const transformFileData = (file, groups) => {
const groupInfos = file.groups.map(
(groupId) =>
@ -77,79 +78,67 @@ export default function FilesGroupsManagement({
Promise.all([
fetchRegistrationSchoolFileMasters(selectedEstablishmentId),
fetchRegistrationFileGroups(selectedEstablishmentId),
fetchRegistrationSchoolFileTemplates(selectedEstablishmentId),
fetchRegistrationParentFileMasters(selectedEstablishmentId),
])
.then(
([
dataSchoolFileMasters,
groupsData,
dataSchoolFileTemplates,
dataParentFileMasters,
]) => {
setGroups(groupsData);
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);
}
)
.then(([dataSchoolFileMasters, groupsData, dataParentFileMasters]) => {
setGroups(groupsData);
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) => {
logger.debug(err.message);
})
.finally(() => {
setReloadTemplates(false);
});
}
}, [reloadTemplates, selectedEstablishmentId]);
}, [selectedEstablishmentId]);
const deleteTemplateMaster = (templateMaster) => {
setRemovePopupVisible(true);
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(() => () => {
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);
setIsLoading(false);
} else {
// Supprimer le formulaire 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 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(
`Erreur lors de la suppression du document "${templateMaster.name}".`,
`Erreur lors de la suppression du formulaire "${templateMaster.name}".`,
'error',
'Erreur'
);
setRemovePopupVisible(false);
})
.finally(() => {
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);
};
const handleCreateTemplateMaster = ({ name, group_ids, id, is_required }) => {
const handleCreateTemplateMaster = ({ name, group_ids, formMasterData }) => {
const data = {
name: name,
id: id,
groups: group_ids,
is_required: is_required,
formMasterData: formMasterData, // Envoyer directement l'objet
};
logger.debug(data);
@ -174,18 +162,32 @@ export default function FilesGroupsManagement({
const transformedFile = transformFileData(data, groups);
setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été créé avec succès.`,
'success',
'Succès'
);
})
.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 = {
name: name,
id: id,
groups: group_ids,
is_required: is_required,
formMasterData: formMasterData,
};
logger.debug(data);
@ -197,11 +199,16 @@ export default function FilesGroupsManagement({
prevFichiers.map((f) => (f.id === id ? transformedFile : f))
);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été modifié avec succès.`,
'success',
'Succès'
);
})
.catch((error) => {
logger.error('Error editing file:', error);
logger.error('Error editing form:', error);
showNotification(
'Erreur lors de la modification du fichier',
'Erreur lors de la modification du formulaire',
'error',
'Erreur'
);
@ -383,24 +390,17 @@ export default function FilesGroupsManagement({
name: 'Actions',
transform: (row) => (
<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
onClick={() => editTemplateMaster(row)}
className="text-blue-500 hover:text-blue-700"
title="Modifier le formulaire"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={() => deleteTemplateMaster(row)}
className="text-red-500 hover:text-red-700"
title="Supprimer le formulaire"
>
<Trash2 className="w-5 h-5" />
</button>
@ -439,23 +439,26 @@ export default function FilesGroupsManagement({
return (
<div className="w-full">
{/* Modal pour les fichiers */}
{/* Modal pour les formulaires */}
<Modal
isOpen={isModalOpen}
setIsOpen={(isOpen) => {
setIsModalOpen(isOpen);
if (!isOpen) {
setFileToEdit(null);
setIsEditing(false);
}
}}
title={isEditing ? 'Modification du document' : 'Ajouter un document'}
modalClassName="w-4/5 h-4/5"
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire'}
modalClassName="w-11/12 h-5/6"
>
<FileUploadDocuSeal
handleCreateTemplateMaster={handleCreateTemplateMaster}
handleEditTemplateMaster={handleEditTemplateMaster}
fileToEdit={fileToEdit}
onSuccess={handleReloadTemplates}
<FormTemplateBuilder
onSave={
isEditing ? handleEditTemplateMaster : handleCreateTemplateMaster
}
initialData={fileToEdit}
groups={groups}
isEditing={isEditing}
/>
</Modal>
@ -496,17 +499,18 @@ export default function FilesGroupsManagement({
/>
</div>
{/* Section Fichiers */}
{/* Section Formulaires */}
<div className="mt-12 mb-4 w-3/5">
<SectionHeader
icon={Signature}
title="Formulaires à remplir"
description="Gérez les formulaires nécessitant une signature électronique."
icon={FileText}
title="Formulaires personnalisés"
description="Créez et gérez vos formulaires d'inscription personnalisés."
button={true}
buttonOpeningModal={true}
onClick={() => {
setIsModalOpen(true);
setIsEditing(false);
setFileToEdit(null);
}}
/>
<Table
@ -516,7 +520,7 @@ export default function FilesGroupsManagement({
<AlertMessage
type="warning"
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"
TEST_MODE=true
FLUSH_DATA=false
MIGRATE_DATA=false
CSRF_COOKIE_SECURE=true
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