7 Commits

Author SHA1 Message Date
abb4b525b2 feat: Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] 2026-01-25 11:01:22 +01:00
b4f70e6bad feat: Sauvegarde des formulaires d'école dans les bons dossiers /
utilisation des bons composants dans les modales [N3WTS-17]
2026-01-18 18:44:13 +01:00
8549699dec feat: Réorganisation items dans la page [N3WTS-17] 2026-01-05 14:56:36 +01:00
a034149eae fix: Coquille [N3WTS-17] 2026-01-03 17:53:53 +01:00
12f5fc7aa9 feat: Changement du rendu de la page des documents + gestion des
formulaires d'école déjà existants [N3WTS-17]
2026-01-03 17:49:25 +01:00
2dc0dfa268 fix: Lint 2025-12-14 16:49:48 +01:00
dd00cba385 feat: Précablage du formulaire dynamique [N3WTS-17] 2025-11-30 17:24:25 +01:00
56 changed files with 4966 additions and 1076 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

@ -2,11 +2,9 @@ from django.db import models
from django.utils.timezone import now
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import os
import logging
import os
logger = logging.getLogger("SubscriptionModels")
@ -316,14 +314,164 @@ class RegistrationForm(models.Model):
#############################################################
####### Formulaires masters (documents école, à signer ou pas) #######
def registration_school_file_master_upload_to(instance, filename):
# Stocke les fichiers masters dans un dossier dédié
# Utilise l'ID si le nom n'est pas encore disponible
est_name = None
if instance.establishment and instance.establishment.name:
est_name = instance.establishment.name
else:
# fallback si pas d'établissement (devrait être rare)
est_name = "unknown_establishment"
return f"{est_name}/Formulaires/{filename}"
class RegistrationSchoolFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
name = models.CharField(max_length=255, default="")
is_required = models.BooleanField(default=False)
formMasterData = models.JSONField(default=list, blank=True, null=True)
file = models.FileField(
upload_to=registration_school_file_master_upload_to,
null=True,
blank=True,
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
def __str__(self):
return f'{self.group.name} - {self.id}'
return f'{self.name} - {self.id}'
@property
def file_url(self):
if self.file and hasattr(self.file, 'url'):
return self.file.url
return None
def save(self, *args, **kwargs):
affected_rf_ids = set()
is_new = self.pk is None
# Log création ou modification du master
if is_new:
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
else:
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
if self.pk:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
if old.file and old.file.name:
old_filename = os.path.basename(old.file.name)
# Nouveau nom selon le type (dynamique ou existant)
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
new_filename = f"{self.name}.pdf"
else:
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
extension = os.path.splitext(old_filename)[1]
new_filename = f"{self.name}{extension}" if extension else self.name
if new_filename and old_filename != new_filename:
old_file_path = old.file.path
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
# Correction du nom du fichier pour éviter le suffixe random
if (
not self.formMasterData
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
):
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
if self.file and self.file.name:
current_filename = os.path.basename(self.file.name)
current_path = self.file.path
expected_filename = new_filename
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
if current_filename != expected_filename:
try:
if os.path.exists(current_path):
os.rename(current_path, expected_path)
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# --- Traitement PDF dynamique AVANT le super().save() ---
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
from Subscriptions.util import generate_form_json_pdf
pdf_filename = f"{self.name}.pdf"
pdf_file = generate_form_json_pdf(self, self.formMasterData)
self.file.save(pdf_filename, pdf_file, save=False)
super().save(*args, **kwargs)
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
try:
# Import local pour éviter le circular import
from Subscriptions.util import create_templates_for_registration_form
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
# Détermination des RF concernés
if is_new:
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
)
else:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
old_groups = set(old.groups.values_list('id', flat=True))
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
)
form_data_changed = (
old.formMasterData != self.formMasterData
and self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
)
name_changed = old.name != self.name
if form_data_changed or name_changed:
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# Pour chaque RF concerné, régénérer les templates
for rf_id in affected_rf_ids:
try:
rf = RegistrationForm.objects.get(pk=rf_id)
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
create_templates_for_registration_form(rf)
except Exception as e:
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
except Exception as e:
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
def delete(self, *args, **kwargs):
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
# Import local pour éviter le circular import
from Subscriptions.models import RegistrationSchoolFileTemplate
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
for tmpl in templates:
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
try:
self.file.delete(save=False)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
super().delete(*args, **kwargs)
####### Parent files masters (documents à fournir par les parents) #######
class RegistrationParentFileMaster(models.Model):
@ -337,10 +485,10 @@ class RegistrationParentFileMaster(models.Model):
############################################################
def registration_school_file_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}"
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
def registration_parent_file_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model):

View File

@ -164,10 +164,18 @@ 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
)
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

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

@ -25,8 +25,6 @@ from .views import (
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
from .views import (
registration_school_file_masters_views,
registration_school_file_templates_views,
get_school_file_templates_by_rf,
get_parent_file_templates_by_rf
)

View File

@ -8,6 +8,9 @@ from N3wtSchool import renderers
from N3wtSchool import bdd
from io import BytesIO
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from django.core.files.base import ContentFile
from django.core.files import File
from pathlib import Path
import os
@ -35,6 +38,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:
@ -88,11 +103,13 @@ def create_templates_for_registration_form(register_form):
from Subscriptions.models import (
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
# RegistrationParentFileMaster,
# RegistrationParentFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
registration_school_file_upload_to,
)
created = []
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
# Récupérer les masters du fileGroup courant
current_group = getattr(register_form, "fileGroup", None)
@ -102,25 +119,26 @@ def create_templates_for_registration_form(register_form):
for t in school_existing:
try:
if getattr(t, "file", None):
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
t.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
t.delete()
# parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
# for t in parent_existing:
# try:
# if getattr(t, "file", None):
# t.file.delete(save=False)
# except Exception:
# logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
# t.delete()
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
for t in parent_existing:
try:
if getattr(t, "file", None):
t.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
t.delete()
return created
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
# parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
school_master_ids = {m.pk for m in school_masters}
#parent_master_ids = {m.pk for m in parent_masters}
parent_master_ids = {m.pk for m in parent_masters}
# Supprimer les school templates obsolètes
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
@ -134,45 +152,119 @@ def create_templates_for_registration_form(register_form):
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
# Supprimer les parent templates obsolètes
# for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
# if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
# try:
# if getattr(tmpl, "file", None):
# tmpl.file.delete(save=False)
# except Exception:
# logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
# tmpl.delete()
# logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
try:
if getattr(tmpl, "file", None):
tmpl.file.delete(save=False)
except Exception:
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
tmpl.delete()
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
# Créer les school templates manquants
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
for m in school_masters:
exists = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
if exists:
continue
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
tmpl = RegistrationSchoolFileTemplate.objects.create(
file_name = None
if m.file and hasattr(m.file, 'name') and m.file.name:
file_name = os.path.basename(m.file.name)
elif m.file:
file_name = str(m.file)
else:
try:
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
file_name = os.path.basename(pdf_file.name)
except Exception as e:
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
file_name = None
from django.core.files.base import ContentFile
upload_rel_path = registration_school_file_upload_to(
type("Tmp", (), {
"registration_form": register_form,
"establishment": getattr(register_form, "establishment", None),
"student": getattr(register_form, "student", None)
})(),
file_name
)
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
if tmpl:
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
master_file_changed = template_file_name != file_name
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
if master_file_changed or (
master_file_path and os.path.exists(master_file_path) and
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
):
# Supprimer l'ancien fichier du template (même si le nom change)
if tmpl.file and tmpl.file.name:
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
if os.path.exists(old_template_path):
try:
os.remove(old_template_path)
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
except Exception as e:
logger.error(f"Erreur suppression ancien fichier template: {e}")
# Copier le nouveau fichier du master (form existant)
if master_file_path and os.path.exists(master_file_path):
try:
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
import shutil
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
tmpl.file.name = upload_rel_path
tmpl.name = m.name or ""
tmpl.slug = slug
tmpl.formTemplateData = m.formMasterData or []
tmpl.save()
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
created.append(tmpl)
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
continue
# Sinon, création du template comme avant
tmpl = RegistrationSchoolFileTemplate(
master=m,
registration_form=register_form,
name=m.name or "",
formTemplateData=m.formMasterData or [],
slug=slug,
)
if file_name:
# Copier le fichier du master si besoin (form existant)
if master_file_path and not os.path.exists(abs_path):
try:
import shutil
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
tmpl.file.name = upload_rel_path
tmpl.save()
created.append(tmpl)
logger.info("Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
# Créer les parent templates manquants
# for m in parent_masters:
# exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
# if exists:
# continue
# tmpl = RegistrationParentFileTemplate.objects.create(
# master=m,
# registration_form=register_form,
# file=None,
# )
# created.append(tmpl)
# logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
for m in parent_masters:
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
if exists:
continue
tmpl = RegistrationParentFileTemplate.objects.create(
master=m,
registration_form=register_form,
file=None,
)
created.append(tmpl)
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
return created
@ -366,3 +458,51 @@ def getHistoricalYears(count=5):
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
return historical_years
def generate_form_json_pdf(register_form, form_json):
"""
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
et l'associe au RegistrationSchoolFileTemplate.
Le PDF contient le titre, les labels et types de champs.
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
"""
# Récupérer le nom du formulaire
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
filename = f"{form_name}.pdf"
# Générer le PDF
buffer = BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
y = 800
# Titre
c.setFont("Helvetica-Bold", 20)
c.drawString(100, y, form_json.get("title", "Formulaire"))
y -= 40
# Champs
c.setFont("Helvetica", 12)
fields = form_json.get("fields", [])
for field in fields:
label = field.get("label", field.get("id", ""))
ftype = field.get("type", "")
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25
if y < 100:
c.showPage()
y = 800
c.save()
buffer.seek(0)
pdf_content = buffer.read()
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
register_form.registration_file.delete(save=False)
# Retourner le ContentFile avec uniquement le nom du fichier
return ContentFile(pdf_content, name=os.path.basename(filename))

View File

@ -10,14 +10,18 @@ from .register_form_views import (
from .registration_school_file_masters_views import (
RegistrationSchoolFileMasterView,
RegistrationSchoolFileMasterSimpleView,
RegistrationParentFileMasterView,
RegistrationParentFileMasterSimpleView,
RegistrationParentFileTemplateSimpleView,
RegistrationParentFileTemplateView
)
from .registration_school_file_templates_views import (
RegistrationSchoolFileTemplateView,
RegistrationSchoolFileTemplateSimpleView,
RegistrationSchoolFileTemplateSimpleView
)
from .registration_parent_file_masters_views import (
RegistrationParentFileMasterView,
RegistrationParentFileMasterSimpleView
)
from .registration_parent_file_templates_views import (
RegistrationParentFileTemplateSimpleView,
RegistrationParentFileTemplateView
)
from .registration_file_group_views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
from .student_views import StudentView, StudentListView, ChildrenListView, search_students

View File

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

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

View File

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

View File

@ -1,20 +1,16 @@
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
from Subscriptions.models import (
RegistrationForm,
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate
RegistrationSchoolFileTemplate
)
from N3wtSchool import bdd
import logging
@ -23,7 +19,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 +61,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 +88,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 +125,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():
@ -139,6 +139,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
if removed_group_ids:
logger.info("REMOVE IDs")
try:
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
for rf in rfs_removed:
@ -151,6 +152,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
if added_group_ids:
logger.info("ADD IDs")
try:
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
for rf in rfs_added:
@ -176,188 +178,13 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
def delete(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is not None:
# Supprimer tous les templates liés et leurs fichiers PDF
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
for template in templates:
if template.file and template.file.name:
template.file.delete(save=False)
template.delete()
master.delete()
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
else:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileMasterView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les fichiers parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les fichiers parents liés à l'établissement
templates = RegistrationParentFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau fichier parent",
request_body=RegistrationParentFileMasterSerializer,
responses={
201: RegistrationParentFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileMasterSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileMasterSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un fichier parent spécifique",
responses={
200: RegistrationParentFileMasterSerializer,
404: "Fichier parent non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un fichier parent existant",
request_body=RegistrationParentFileMasterSerializer,
responses={
200: RegistrationParentFileMasterSerializer,
400: "Données invalides",
404: "Fichier parent non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Fichier parent mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un fichier parent",
responses={
204: "Suppression réussie",
404: "Fichier parent non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
templates = RegistrationParentFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationParentFileTemplateSerializer,
responses={
201: RegistrationParentFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationParentFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationParentFileTemplateSerializer,
responses={
200: RegistrationParentFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -5,8 +5,7 @@ from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
import json
from django.http import QueryDict
import os
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
@ -209,6 +208,17 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé
if template.file and template.file.name:
file_path = template.file.path
template.file.delete(save=False)
# Vérification post-suppression
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.info(f"Fichier supprimé manuellement: {file_path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
@ -390,6 +400,17 @@ class RegistrationParentFileTemplateSimpleView(APIView):
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé
if template.file and template.file.name:
file_path = template.file.path
template.file.delete(save=False)
# Vérification post-suppression
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.info(f"Fichier supprimé manuellement: {file_path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:

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

@ -34,9 +34,7 @@ import {
import {
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters,
createRegistrationSchoolFileTemplate,
createRegistrationParentFileTemplate,
fetchRegistrationParentFileMasters
} from '@/app/actions/registerFileGroupAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';

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

@ -100,12 +100,12 @@ export async function createRegistrationFileGroup(groupData, csrfToken) {
}
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
// Toujours FormData, jamais JSON
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
method: 'POST',
body: JSON.stringify(data),
body: data,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
@ -186,10 +186,9 @@ export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{
method: 'PUT',
body: JSON.stringify(data),
body: data,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}

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,7 +401,8 @@ export default function AddFieldModal({
/>
</div>
<div className="space-y-1">
{currentField.options.map((option, index) => (
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
@ -396,7 +445,8 @@ export default function AddFieldModal({
/>
</div>
<div className="space-y-1">
{currentField.options.map((option, index) => (
{Array.isArray(currentField.options) &&
currentField.options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded"
@ -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,7 +35,9 @@ import {
ToggleLeft,
CheckSquare,
FileUp,
PenTool,
} from 'lucide-react';
import CheckBox from '@/components/Form/CheckBox';
const FIELD_TYPES_ICON = {
text: { icon: TextCursorInput },
@ -46,6 +49,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 +172,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 +200,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 +263,9 @@ export default function FormTemplateBuilder() {
? undefined
: generateFieldId(data.label || 'field'),
options: ['select', 'radio'].includes(data.type)
? Array.isArray(currentField.options)
? currentField.options
: []
: undefined,
icon: data.icon || currentField.icon || undefined,
placeholder: data.placeholder || undefined,
@ -345,35 +375,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 +416,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 +514,42 @@ 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&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroups
}}
handleChange={() => {
let group_ids = selectedGroups;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
} else {
group_ids = [...group_ids, group.id];
}
setSelectedGroups(group_ids);
}}
fieldName="groups"
itemLabelFunc={() => group.name}
/>
))
) : (
<p className="text-gray-500 text-sm">
Aucun groupe disponible
</p>
)}
</div>
</div>
</div>
{/* Liste des champs */}
@ -487,7 +561,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 +579,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 +669,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,6 +63,131 @@ export default function SelectChoice({
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
{customSelect ? (
// Version personnalisée avec icônes dans un dropdown
<div ref={dropdownRef} className="relative">
<div
className={`mt-1 flex items-center border rounded-md cursor-pointer ${
errorMsg || errorLocalMsg
? 'border-red-500 hover:border-red-700'
: 'border-gray-200 hover:border-gray-400'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{IconItem && (
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
{<IconItem />}
</span>
)}
<div className="flex-1 px-3 py-2 block w-full sm:text-sm">
{isPlaceholderSelected ? (
<span className="italic text-gray-500">
{placeHolder?.toLowerCase()}
</span>
) : (
<div className="flex items-center">
{showIcons && selected && choices && (
<span className="mr-2">
{(() => {
const selectedOption = choices.find(
(option) => option.value === selected
);
if (selectedOption && selectedOption.icon) {
const IconComponent = selectedOption.icon;
return (
<IconComponent
size={18}
className="text-gray-600"
/>
);
}
return null;
})()}
</span>
)}
<span className="text-gray-800">{selectedLabel}</span>
</div>
)}
</div>
<div className="pr-2">
<svg
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
{isOpen && (
<div className="absolute z-10 mt-1 w-full bg-white shadow-lg rounded-md py-1 max-h-60 overflow-auto">
<div
className="px-3 py-2 text-gray-500 italic cursor-pointer hover:bg-gray-100"
onClick={() => {
if (!disabled) {
const event = { target: { value: '' } };
callback(event);
setIsOpen(false);
}
}}
>
{placeHolder?.toLowerCase()}
</div>
{choices.map(({ value, label, icon: Icon }) => (
<div
key={value}
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 ${
value === selected
? 'bg-blue-50 text-blue-700'
: 'text-gray-800'
}`}
onClick={() => {
if (!disabled) {
const event = { target: { value } };
callback(event);
setIsOpen(false);
}
}}
>
<div className="flex items-center">
{showIcons && Icon && (
<span className="mr-2">
<Icon
size={18}
className={
value === selected
? 'text-blue-600'
: 'text-gray-600'
}
/>
</span>
)}
<span>{label}</span>
</div>
</div>
))}
</div>
)}
{/* Input caché pour la compatibilité avec les formulaires */}
<input
type="hidden"
name={name}
id={name}
value={selected || ''}
onChange={() => {}} // Évite l'avertissement React pour input non contrôlé
/>
</div>
) : (
// Version standard avec select natif (pour la rétrocompatibilité)
<div
className={`mt-1 flex items-center border rounded-md ${
errorMsg || errorLocalMsg
@ -38,7 +203,7 @@ export default function SelectChoice({
<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
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`}
type={type}
id={name}
name={name}
@ -62,6 +227,8 @@ export default function SelectChoice({
))}
</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&apos;est pas encore configuré.
</p>
<p className="text-sm text-gray-500 mt-2">
Contactez l&apos;administration pour plus d&apos;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&apos;é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,
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,
});
// Préparer les données pour la mise à jour
const updateData = new FormData();
updateData.append('file', file);
// 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;
};
// Mettre à jour le template via l'API
return editRegistrationSchoolFileTemplates(
template.id,
updateData,
// 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
);
})
.then((updatedTemplate) => {
logger.debug('Template mis à jour avec succès :', updatedTemplate);
logger.debug("Réponse de l'API:", result);
// Mettre à jour l'état local de schoolFileTemplates
setSchoolFileTemplates((prevTemplates) => {
const updatedTemplates = prevTemplates.map((t, i) =>
i === index ? { ...t, file: updatedTemplate.data.file } : t
// 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(
'État schoolFileTemplates mis à jour :',
updatedTemplates
);
return updatedTemplates;
});
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du template :', error);
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)
}
<DynamicFormsList
schoolFileMasters={schoolFileMasters}
existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange}
enable={enable}
/>
) : (
<iframe
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
title="Document Viewer"
className="w-full"
style={{
height: '75vh',
border: 'none',
}}
/>
)}
</div>
)}
{/* Message de fin */}
{currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center text-green-600 font-semibold">
Tous les formulaires ont été signés avec succès !
</div>
)}
</div>
</div>
)}
{/* Dernière page : Section Fichiers parents */}

View File

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

View File

@ -0,0 +1,209 @@
import React, { useState } from 'react';
import Modal from '@/components/Modal';
import { FolderPlus, FileText, FilePlus2, ArrowLeft, Settings2, Upload as UploadIcon } from 'lucide-react';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import FileUpload from '@/components/Form/FileUpload';
export default function CreateDocumentModal({
isOpen,
onClose,
onCreateGroup,
onCreateParentFile,
onCreateSchoolFileMaster,
groups = [],
}) {
const [step, setStep] = useState('main'); // main | choose_form | form_builder | file_upload
const [fileName, setFileName] = useState('');
const [selectedGroupsFileUpload, setSelectedGroupsFileUpload] = useState([]);
const [uploadedFile, setUploadedFile] = useState(null);
React.useEffect(() => {
if (!isOpen) {
setStep('main');
setFileName('');
setSelectedGroupsFileUpload([]);
setUploadedFile(null);
}
}, [isOpen]);
// Handler pour chaque type
const handleSelect = (type) => {
if (type === 'groupe') {
setStep('main');
onCreateGroup();
onClose();
}
if (type === 'formulaire') {
setStep('choose_form');
}
if (type === 'parent') {
setStep('main');
onCreateParentFile();
onClose();
}
};
// Retour au menu principal
const handleBack = () => setStep('main');
// Submit pour formulaire existant
const handleFileUploadSubmit = (e) => {
e.preventDefault();
if (!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile) return;
onCreateSchoolFileMaster({
name: fileName,
group_ids: selectedGroupsFileUpload,
file: uploadedFile,
});
onClose();
};
return (
<Modal
isOpen={isOpen}
setIsOpen={onClose}
title="Créer un document"
modalClassName="w-full max-w-md"
>
{step === 'main' && (
<div className="flex flex-col gap-6 py-4">
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-blue-50 hover:bg-blue-100 border border-blue-200 transition"
onClick={() => handleSelect('groupe')}
>
<FolderPlus className="w-6 h-6 text-blue-600" />
<span className="font-semibold text-blue-800">Dossier d&aposinscription</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition"
onClick={() => handleSelect('formulaire')}
>
<FileText className="w-6 h-6 text-emerald-600" />
<span className="font-semibold text-emerald-800">Formulaire scolaire</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-orange-50 hover:bg-orange-100 border border-orange-200 transition"
onClick={() => handleSelect('parent')}
>
<FilePlus2 className="w-6 h-6 text-orange-500" />
<span className="font-semibold text-orange-700">Pièce à fournir</span>
</button>
</div>
)}
{step === 'choose_form' && (
<div className="flex flex-col gap-4 py-4">
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-100 hover:bg-emerald-200 border border-emerald-300 transition"
onClick={() => setStep('form_builder')}
>
<Settings2 className="w-6 h-6 text-emerald-700" />
<span className="font-semibold text-emerald-900">Formulaire personnalisé</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-300 transition"
onClick={() => setStep('file_upload')}
>
<UploadIcon className="w-6 h-6 text-gray-700" />
<span className="font-semibold text-gray-900">Importer un formulaire existant</span>
</button>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mt-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
</div>
)}
{step === 'form_builder' && (
<div>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
<FormTemplateBuilder
onSave={(data) => {
onCreateSchoolFileMaster(data);
onClose();
}}
groups={groups}
isEditing={false}
/>
</div>
)}
{step === 'file_upload' && (
<div>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
<form className="flex flex-col gap-4" onSubmit={handleFileUploadSubmit}>
<input
type="text"
className="border rounded px-3 py-2"
placeholder="Nom du formulaire"
value={fileName}
onChange={e => setFileName(e.target.value)}
required
/>
{/* Sélecteur de groupes à cocher */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
</label>
<div className="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={selectedGroupsFileUpload.includes(group.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedGroupsFileUpload([
...selectedGroupsFileUpload,
group.id,
]);
} else {
setSelectedGroupsFileUpload(
selectedGroupsFileUpload.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>
<FileUpload
selectionMessage="Sélectionnez le fichier du formulaire"
onFileSelect={setUploadedFile}
required
enable
/>
<button
type="submit"
className="bg-emerald-600 text-white px-4 py-2 rounded font-bold mt-2"
disabled={!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile}
>
Créer le formulaire
</button>
</form>
</div>
)}
</Modal>
);
}

View File

@ -10,8 +10,8 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import Popup from '@/components/Popup';
export default function FileUploadDocuSeal({
handleCreateTemplateMaster,
handleEditTemplateMaster,
handleCreateSchoolFileMaster,
handleEditSchoolFileMaster,
fileToEdit = null,
onSuccess,
}) {
@ -75,7 +75,7 @@ export default function FileUploadDocuSeal({
const is_required = data.fields.length > 0;
if (fileToEdit) {
logger.debug('Modification du template master:', templateMaster?.id);
handleEditTemplateMaster({
handleEditSchoolFileMaster({
name: uploadedFileName,
group_ids: selectedGroups.map((group) => group.id),
id: templateMaster?.id,
@ -83,7 +83,7 @@ export default function FileUploadDocuSeal({
});
} else {
logger.debug('Création du template master:', templateMaster?.id);
handleCreateTemplateMaster({
handleCreateSchoolFileMaster({
name: uploadedFileName,
group_ids: selectedGroups.map((group) => group.id),
id: templateMaster?.id,

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "n3wt-school",
"version": "0.0.1",
"version": "0.0.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n3wt-school",
"version": "0.0.1",
"version": "0.0.3",
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",