feat: Changement du rendu de la page des documents + gestion des

formulaires d'école déjà existants [N3WTS-17]
This commit is contained in:
N3WT DE COMPET
2026-01-03 17:49:25 +01:00
parent 2dc0dfa268
commit 12f5fc7aa9
17 changed files with 1622 additions and 423 deletions

View File

@ -316,14 +316,31 @@ 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é
return f"registration_files/school_file_masters/{instance.pk}/{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)
# Nouveau champ pour formulaire existant (PDF, DOC, etc.)
file = models.FileField(
upload_to=registration_school_file_master_upload_to,
null=True,
blank=True,
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
)
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
####### Parent files masters (documents à fournir par les parents) #######
class RegistrationParentFileMaster(models.Model):
@ -337,10 +354,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"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/school/{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

@ -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
@ -100,8 +103,9 @@ def create_templates_for_registration_form(register_form):
from Subscriptions.models import (
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
# RegistrationParentFileMaster,
# RegistrationParentFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
registration_school_file_upload_to,
)
created = []
@ -114,25 +118,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):
@ -146,15 +151,15 @@ 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
for m in school_masters:
@ -163,28 +168,58 @@ def create_templates_for_registration_form(register_form):
continue
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
# Si le master a un fichier uploadé (formulaire existant)
file_to_attach = None
if m.file:
import os
from django.core.files import File as DjangoFile
master_file_path = m.file.path
if os.path.exists(master_file_path):
filename = os.path.basename(master_file_path)
# Générer le chemin cible pour le template élève
dest_path = registration_school_file_upload_to(None, filename)
dest_dir = os.path.dirname(os.path.join(settings.MEDIA_ROOT, dest_path))
os.makedirs(dest_dir, exist_ok=True)
# Copier le fichier dans le dossier cible
dest_full_path = os.path.join(settings.MEDIA_ROOT, dest_path)
with open(master_file_path, 'rb') as src, open(dest_full_path, 'wb') as dst:
dst.write(src.read())
# Préparer le File Django à attacher au template
with open(dest_full_path, 'rb') as f:
file_to_attach = DjangoFile(f, name=dest_path)
else:
# Générer le PDF du template à partir du JSON du master
try:
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
file_to_attach = pdf_file
except Exception as e:
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
file_to_attach = None
tmpl = RegistrationSchoolFileTemplate.objects.create(
master=m,
registration_form=register_form,
name=m.name or "",
formTemplateData=m.formMasterData or [],
slug=slug,
file=file_to_attach,
)
created.append(tmpl)
logger.info("Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
# Créer les parent templates manquants
# for m in parent_masters:
# exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
# if exists:
# continue
# tmpl = RegistrationParentFileTemplate.objects.create(
# master=m,
# registration_form=register_form,
# file=None,
# )
# created.append(tmpl)
# logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
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
@ -377,4 +412,52 @@ def getHistoricalYears(count=5):
historical_start_year = start_year - i
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
return historical_years
return historical_years
def generate_form_json_pdf(register_form, form_json):
"""
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
et l'associe au RegistrationSchoolFileTemplate.
Le PDF contient le titre, les labels et types de champs.
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
"""
# Récupérer le nom du formulaire
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
filename = f"{form_name}.pdf"
# Générer le PDF
buffer = BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
y = 800
# Titre
c.setFont("Helvetica-Bold", 20)
c.drawString(100, y, form_json.get("title", "Formulaire"))
y -= 40
# Champs
c.setFont("Helvetica", 12)
fields = form_json.get("fields", [])
for field in fields:
label = field.get("label", field.get("id", ""))
ftype = field.get("type", "")
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25
if y < 100:
c.showPage()
y = 800
c.save()
buffer.seek(0)
pdf_content = buffer.read()
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
register_form.registration_file.delete(save=False)
# Retourner le ContentFile avec uniquement le nom du fichier
return ContentFile(pdf_content, name=os.path.basename(filename))

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

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

@ -5,15 +5,12 @@ 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, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
from Subscriptions.models import (
RegistrationForm,
RegistrationSchoolFileMaster,
RegistrationParentFileMaster,
RegistrationParentFileTemplate
RegistrationSchoolFileTemplate
)
from N3wtSchool import bdd
import logging
@ -179,188 +176,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

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

@ -100,12 +100,13 @@ 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',
// Pas de Content-Type, le navigateur gère pour FormData
},
credentials: 'include',
})

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

@ -1,16 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Download,
Edit3,
Trash2,
FolderPlus,
FileText,
AlertTriangle,
} from 'lucide-react';
import Modal from '@/components/Modal';
import Table from '@/components/Table';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import { BASE_URL } from '@/utils/Url';
import {
// GET
fetchRegistrationFileGroups,
@ -31,13 +25,110 @@ import {
} from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger';
import ParentFilesSection from '@/components/Structure/Files/ParentFilesSection';
import SectionHeader from '@/components/SectionHeader';
import ParentFiles from './ParentFiles';
import Popup from '@/components/Popup';
import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage';
import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal';
import CreateDocumentModal from '@/components/Structure/Files/CreateDocumentModal';
import FileUpload from '@/components/Form/FileUpload';
function getItemBgColor(type, selected, forceTheme = false) {
// Colonne gauche : bleu, sélectionné plus soutenu
if (type === 'blue') {
if (selected) return 'bg-blue-200';
return 'bg-blue-50';
}
// Colonne droite : thème selon type, jamais sélectionné
if (forceTheme) {
if (type === 'emerald') return 'bg-emerald-50';
if (type === 'orange') return 'bg-orange-50';
return 'bg-gray-50';
}
return 'bg-white';
}
function SimpleList({
items,
onSelect,
selectedId,
actionButtons,
getItemType,
title,
minHeight = 'min-h-[200px]',
selectable = true,
forceTheme = false,
}) {
return (
<div className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}>
{title && (
<div
className={`
px-4 py-3
font-bold text-base
border-b border-gray-300
bg-gradient-to-r
${title === "Dossiers d'inscription"
? 'from-blue-100 to-blue-50 text-blue-800'
: title === 'Documents'
? 'from-emerald-100 to-orange-50 text-emerald-800'
: 'from-gray-100 to-white text-gray-800'}
rounded-t
shadow-sm
tracking-wide
uppercase
flex items-center
`}
>
{title}
</div>
)}
<ul className="flex-1">
{items.length === 0 ? (
<li className="py-4 text-center text-gray-400">Aucun élément</li>
) : (
items.map((item, idx) => {
// Correction : clé unique et stable même si plusieurs items ont le même id
// On concatène l'id et l'index pour garantir l'unicité
const key = `${item.id}-${idx}`;
const selected = selectedId === item.id;
const itemType = getItemType ? getItemType(item) : 'gray';
const bgColor = getItemBgColor(itemType, selected, forceTheme);
const zIndex =
selectable && selected
? 'z-10 relative'
: selectable
? 'z-0 relative'
: '';
const marginFix =
selectable && idx !== items.length - 1
? '-mb-[1px]'
: '';
return (
<li
key={key}
className={`flex items-center justify-between px-4 py-3 transition ${bgColor} ${selected && selectable ? 'ring-2 ring-blue-400' : ''} ${selectable ? 'cursor-pointer' : ''} ${zIndex} ${marginFix}`}
onClick={() => {
if (!selectable || !onSelect) return;
if (selected) {
onSelect(null);
} else {
onSelect(item.id);
}
}}
tabIndex={0}
aria-selected={selected}
role="option"
>
<span className="font-medium text-gray-800">{item.name}</span>
{actionButtons && actionButtons(item)}
</li>
);
})
)}
</ul>
</div>
);
}
export default function FilesGroupsManagement({
csrfToken,
@ -57,22 +148,34 @@ export default function FilesGroupsManagement({
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [isLoading, setIsLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createModalKey, setCreateModalKey] = useState(0);
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [showHelp, setShowHelp] = useState(false);
const { showNotification } = useNotification();
const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false);
const [editingParentFile, setEditingParentFile] = useState(null);
const [isFileUploadModalOpen, setIsFileUploadModalOpen] = useState(false);
const transformFileData = (file, groups) => {
const groupInfos = file.groups.map(
(groupId) =>
groups.find((g) => g.id === groupId) || {
id: groupId,
name: 'Groupe inconnu',
}
);
// file.groups peut contenir des IDs (number/string) ou des objets {id, name}
const groupInfos = (file.groups || []).map((group) => {
if (typeof group === 'object' && group !== null && 'id' in group) {
// Déjà un objet groupe
const found = groups.find((g) => g.id === group.id);
return found || group;
} else {
// C'est un ID
return groups.find((g) => g.id === group) || { id: group, name: 'Groupe inconnu' };
}
});
return {
...file,
groups: groupInfos,
};
};
// Ne pas transformer ici, stocker la donnée brute
useEffect(() => {
if (selectedEstablishmentId) {
Promise.all([
@ -83,11 +186,7 @@ export default function FilesGroupsManagement({
.then(([dataSchoolFileMasters, groupsData, dataParentFileMasters]) => {
setGroups(groupsData);
setParentFileMasters(dataParentFileMasters);
// Transformer chaque fichier pour inclure les informations complètes du groupe
const transformedFiles = dataSchoolFileMasters.map((file) =>
transformFileData(file, groupsData)
);
setSchoolFileMasters(transformedFiles);
setSchoolFileMasters(dataSchoolFileMasters); // donnée brute
})
.catch((err) => {
logger.debug(err.message);
@ -148,19 +247,22 @@ export default function FilesGroupsManagement({
setIsModalOpen(true);
};
const handleCreateTemplateMaster = ({ name, group_ids, formMasterData }) => {
const data = {
name: name,
const handleCreateSchoolFileMaster = ({ name, group_ids, formMasterData, file }) => {
// Toujours envoyer en FormData, même sans fichier
const dataToSend = new FormData();
const jsonData = {
name,
groups: group_ids,
formMasterData: formMasterData, // Envoyer directement l'objet
formMasterData,
};
logger.debug(data);
dataToSend.append('data', JSON.stringify(jsonData));
if (file) {
dataToSend.append('file', file, file.path || file.name);
}
createRegistrationSchoolFileMaster(data, csrfToken)
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
.then((data) => {
// Transformer le nouveau fichier avec les informations du groupe
const transformedFile = transformFileData(data, groups);
setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]);
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été créé avec succès.`,
@ -178,7 +280,7 @@ export default function FilesGroupsManagement({
});
};
const handleEditTemplateMaster = ({
const handleEditSchoolFileMaster = ({
name,
group_ids,
formMasterData,
@ -193,10 +295,8 @@ export default function FilesGroupsManagement({
editRegistrationSchoolFileMaster(id, data, csrfToken)
.then((data) => {
// Transformer le fichier mis à jour avec les informations du groupe
const transformedFile = transformFileData(data, groups);
setSchoolFileMasters((prevFichiers) =>
prevFichiers.map((f) => (f.id === id ? transformedFile : f))
prevFichiers.map((f) => (f.id === id ? data : f))
);
setIsModalOpen(false);
showNotification(
@ -292,6 +392,10 @@ export default function FilesGroupsManagement({
throw new Error('Erreur lors de la suppression du groupe.');
}
setGroups(groups.filter((group) => group.id !== groupId));
// Si le groupe supprimé était sélectionné, on désélectionne
if (String(selectedGroupId) === String(groupId)) {
setSelectedGroupId(null);
}
setRemovePopupVisible(false);
setIsLoading(false);
showNotification('Groupe supprimé avec succès.', 'success', 'Succès');
@ -331,15 +435,22 @@ export default function FilesGroupsManagement({
};
const handleEdit = (id, updatedFile) => {
logger.debug('[FilesGroupsManagement] handleEdit called with:', id, updatedFile);
// Correction : vérifier si updatedFile est bien un objet (et pas juste un id)
if (typeof updatedFile !== 'object' || updatedFile === null) {
logger.error('[FilesGroupsManagement] handleEdit: updatedFile is not an object', updatedFile);
return Promise.reject(new Error('updatedFile is not an object'));
}
logger.debug('[FilesGroupsManagement] handleEdit payload:', JSON.stringify(updatedFile));
return editRegistrationParentFileMaster(id, updatedFile, csrfToken)
.then((response) => {
logger.debug('[FilesGroupsManagement] editRegistrationParentFileMaster response:', response);
const modifiedFile = response.data; // Extraire les données mises à jour
// Mettre à jour la liste des fichiers parents
setParentFileMasters((prevFiles) =>
prevFiles.map((file) => (file.id === id ? modifiedFile : file))
);
logger.debug('Document parent mis à jour avec succès:', modifiedFile);
return modifiedFile; // Retourner le fichier mis à jour
return modifiedFile;
})
.catch((error) => {
logger.error(
@ -369,77 +480,321 @@ export default function FilesGroupsManagement({
});
};
const filteredFiles = schoolFileMasters.filter((file) => {
if (!selectedGroup) return true;
// Ouvre la modale de création d'une pièce à fournir
const openParentFileModal = () => {
setEditingParentFile(null);
setIsParentFileModalOpen(true);
};
// Ouvre la modale d'édition d'une pièce à fournir
const openEditParentFileModal = (file) => {
setEditingParentFile(file);
setIsParentFileModalOpen(true);
};
// Ferme la modale de pièce à fournir
const closeParentFileModal = () => {
setEditingParentFile(null);
setIsParentFileModalOpen(false);
};
// Ouvre le menu de choix pour formulaire d'école
const handleCreateFormMenu = () => {
setIsCreateModalOpen(false);
setTimeout(() => setIsCreateModalOpen(true), 0); // force le reset du menu
};
// Ouvre la modale FormTemplateBuilder
const handleOpenFormTemplateBuilder = () => {
setIsModalOpen(true);
setIsEditing(false);
setFileToEdit(null);
};
// Ouvre la modale FileUpload
const handleOpenFileUpload = () => {
setIsFileUploadModalOpen(true);
};
// Ferme la modale FileUpload
const handleCloseFileUpload = () => {
setIsFileUploadModalOpen(false);
};
// Soumission du formulaire existant (upload)
const handleSubmitFileUpload = ({ name, group_ids, file }) => {
// On centralise la logique dans handleCreateSchoolFileMaster
handleCreateSchoolFileMaster({ name, group_ids, file });
setIsFileUploadModalOpen(false);
};
// Filtrage des formulaires et pièces selon le dossier sélectionné
// Appliquer la transformation à la volée ici et comparer en string
const filteredFiles = schoolFileMasters
.map((file) => transformFileData(file, groups))
.filter((file) => {
if (!selectedGroupId) return false;
return (
file.groups &&
file.groups.some((group) => String(group.id) === String(selectedGroupId))
);
});
const filteredParentFiles = parentFiles.filter((file) => {
if (!selectedGroupId) return false;
return (
file.groups &&
file.groups.some((group) => group.id === parseInt(selectedGroup))
file.groups.map(String).includes(String(selectedGroupId))
);
});
const columnsFiles = [
{ name: 'Nom du formulaire', transform: (row) => row.name },
{
name: "Dossiers d'inscription",
transform: (row) =>
row.groups && row.groups.length > 0
? row.groups.map((group) => group.name).join(', ')
: 'Aucun',
},
{
name: 'Actions',
transform: (row) => (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => editTemplateMaster(row)}
className="text-blue-500 hover:text-blue-700"
title="Modifier le formulaire"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={() => deleteTemplateMaster(row)}
className="text-red-500 hover:text-red-700"
title="Supprimer le formulaire"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
),
},
// Fusion des deux types de documents pour la colonne de droite
const mergedDocuments = [
...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })),
...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })),
];
const columnsGroups = [
{ name: 'Nom du dossier', transform: (row) => row.name },
{ name: 'Description', transform: (row) => row.description },
{
name: 'Actions',
transform: (row) => (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleGroupEdit(row)}
className="text-blue-500 hover:text-blue-700"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={() => handleGroupDelete(row.id)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="w-5 h-5" />
</button>
// Nouvelle explication: adaptée au contexte "dossier lié à une classe/groupe de classes"
const renderExplanation = () => (
<div className="mb-4">
<button
className="flex items-center gap-2 text-emerald-700 hover:text-emerald-900 font-medium mb-2"
onClick={() => setShowHelp((v) => !v)}
aria-expanded={showHelp}
aria-controls="aide-inscription"
>
<span className="underline">{showHelp ? 'Masquer' : 'Afficher'} laide</span>
<svg className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showHelp && (
<div id="aide-inscription" className="p-4 bg-blue-50 border border-blue-200 rounded mb-4">
<h2 className="text-lg font-bold mb-2">
Gestion des dossiers et documents d&apos;inscription
</h2>
<div className="text-gray-700 space-y-2">
<p>
<span className="font-semibold">1. Créez un ou plusieurs <span className="text-blue-700">dossiers d&aposinscription</span></span> :<br />
Chaque dossier correspond à une classe ou un groupe de classes (ex : <span className="italic">Dossier Maternelles</span>, <span className="italic">Dossier Élémentaires</span>). Lors de la création d&apos;une inscription élève, un seul dossier d&apos;inscription sera rattaché à l&apos;élève.
</p>
<p>
<span className="font-semibold">2. Pour chaque dossier, ajoutez des documents à fournir :</span>
</p>
<ul className="list-disc list-inside ml-6">
<li>
<span className="text-emerald-700 font-semibold">Formulaires personnalisés</span> : créés dynamiquement par l&aposécole, à remplir et/ou signer électroniquement par la famille (ex : autorisation de sortie, fiche sanitaire, etc.).
</li>
<li>
<span className="text-orange-700 font-semibold">Pièces à fournir</span> : documents à déposer par la famille (ex : RIB, justificatif de domicile, ou formulaire PDF à télécharger, remplir puis ré-uploader).
</li>
</ul>
<div className="mt-2 text-sm text-gray-600">
<span className="font-semibold">Astuce :</span> Commencez toujours par créer vos dossiers d&aposinscription (liés à vos classes) avant d&aposajouter des documents à fournir.
</div>
</div>
</div>
),
},
];
)}
</div>
);
if (isLoading) {
return <Loader />;
}
// Correction : définition de handleBackToCreateMenu
const handleBackToCreateMenu = () => {
setCreateModalKey((k) => k + 1); // force le reset du composant modal
setIsCreateModalOpen(true);
};
// Nouvelle disposition: sections côte à côte alignées
return (
<div className="w-full">
{/* Modal pour les formulaires */}
{/* Aide optionnelle + bouton global de création */}
<div className="mb-8">
{renderExplanation()}
<div className="flex justify-center">
<button
className="flex items-center justify-center gap-3 bg-emerald-500 hover:bg-emerald-600 text-white px-8 py-4 rounded-xl shadow transition text-lg font-bold"
style={{ minWidth: '320px', maxWidth: '400px' }}
onClick={() => setIsCreateModalOpen(true)}
>
<span className="text-2xl font-bold">+</span>
<span>Créer un nouvel élément</span>
</button>
</div>
</div>
<div className="flex gap-8">
{/* Colonne gauche : Dossiers d'inscription */}
<div className="w-[30%] min-w-[260px]">
<SimpleList
items={groups}
selectedId={selectedGroupId}
onSelect={setSelectedGroupId}
getItemType={() => 'blue'}
title="Dossiers d'inscription"
minHeight="min-h-[240px]"
selectable={true}
forceTheme={false}
actionButtons={(row) => (
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleGroupEdit(row); }}
className="text-blue-500 hover:text-blue-700"
title="Modifier"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleGroupDelete(row.id); }}
className="text-red-500 hover:text-red-700"
title="Supprimer"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
)}
/>
</div>
{/* Colonne droite : Documents (fusion des formulaires et pièces à fournir) */}
<div className="w-[70%] min-w-[320px]">
<div className="rounded border border-gray-200 bg-white min-h-[240px] flex flex-col">
<div
className={`
px-4 py-3
font-bold text-base
border-b border-gray-300
bg-gradient-to-r
from-emerald-100 to-orange-50 text-emerald-800
rounded-t
shadow-sm
tracking-wide
uppercase
flex items-center
`}
>
Documents
</div>
{selectedGroupId === null ? (
<div className="flex items-center justify-center flex-1 bg-white text-gray-400 text-center text-base">
Sélectionnez un dossier pour voir les documents associés.
</div>
) : (
<SimpleList
key={selectedGroupId}
items={mergedDocuments}
selectedId={null}
onSelect={null}
getItemType={(item) => item._type}
// title="Documents" // Supprimé ici, header déjà affiché au-dessus
minHeight="min-h-[240px]"
selectable={false}
forceTheme={true}
actionButtons={(row) => (
<div className="flex items-center gap-2">
{row._type === 'emerald' ? (
<>
<button
onClick={(e) => { e.stopPropagation(); editTemplateMaster(row); }}
className="text-blue-500 hover:text-blue-700"
title="Modifier"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); deleteTemplateMaster(row); }}
className="text-red-500 hover:text-red-700"
title="Supprimer"
>
<Trash2 className="w-5 h-5" />
</button>
</>
) : (
<>
<button
onClick={(e) => { e.stopPropagation(); openEditParentFileModal(row); }}
className="text-blue-500 hover:text-blue-700"
title="Modifier"
>
<Edit3 className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}
className="text-red-500 hover:text-red-700"
title="Supprimer"
>
<Trash2 className="w-5 h-5" />
</button>
</>
)}
</div>
)}
/>
)}
</div>
</div>
</div>
{/* Modal de création centralisée */}
<CreateDocumentModal
key={createModalKey}
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreateGroup={() => {
setIsGroupModalOpen(true);
setIsCreateModalOpen(false);
}}
onCreateForm={() => {
setIsCreateModalOpen(false);
setTimeout(() => setIsModalOpen(true), 0);
}}
onCreateParentFile={() => {
openParentFileModal();
setIsCreateModalOpen(false);
}}
onBack={handleBackToCreateMenu}
onCreateSchoolFileMaster={handleCreateSchoolFileMaster}
groups={groups}
/>
{/* Modal pour upload de formulaire existant */}
<Modal
isOpen={isFileUploadModalOpen}
setIsOpen={handleCloseFileUpload}
title="Importer un formulaire existant"
modalClassName="w-full max-w-md"
>
<FileUpload
selectionMessage="Sélectionnez le fichier du formulaire"
onFileSelect={(file) => {
// Ici, il faut gérer le nom et les groupes (à adapter selon vos besoins UI)
// Par exemple, ouvrir un petit formulaire pour compléter les infos puis submit
}}
onSubmit={handleSubmitFileUpload}
required
enable
/>
</Modal>
{/* Modal création/édition pièce à fournir */}
<Modal
isOpen={isParentFileModalOpen}
setIsOpen={(open) => {
if (!open) closeParentFileModal();
}}
title={editingParentFile ? 'Modifier la pièce à fournir' : 'Créer une pièce à fournir'}
modalClassName="w-full max-w-md"
>
<ParentFiles
parentFiles={parentFiles}
groups={groups}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
singleForm // affiche uniquement le formulaire, pas la liste
initialData={editingParentFile}
onCancel={closeParentFileModal}
/>
</Modal>
{/* Modals et popups */}
<Modal
isOpen={isModalOpen}
setIsOpen={(isOpen) => {
@ -447,100 +802,51 @@ export default function FilesGroupsManagement({
if (!isOpen) {
setFileToEdit(null);
setIsEditing(false);
// Retour au menu principal de création si fermeture
handleBackToCreateMenu();
}
}}
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire'}
modalClassName="w-11/12 h-5/6"
>
<FormTemplateBuilder
onSave={
isEditing ? handleEditTemplateMaster : handleCreateTemplateMaster
}
onSave={(data) => {
(isEditing ? handleEditSchoolFileMaster : handleCreateSchoolFileMaster)(data);
}}
initialData={fileToEdit}
groups={groups}
isEditing={isEditing}
/>
</Modal>
{/* Modal pour les groupes */}
<Modal
isOpen={isGroupModalOpen}
setIsOpen={setIsGroupModalOpen}
setIsOpen={(isOpen) => {
setIsGroupModalOpen(isOpen);
if (!isOpen) {
// Retour au menu principal de création si fermeture
handleBackToCreateMenu();
}
}}
title={
groupToEdit ? 'Modifier le dossier' : "Création d'un nouveau dossier"
}
>
<RegistrationFileGroupForm
onSubmit={handleGroupSubmit}
onSubmit={(data) => {
handleGroupSubmit(data);
}}
initialData={groupToEdit}
/>
</Modal>
{/* Section Groupes de fichiers */}
<div className="mt-8 w-3/5">
<SectionHeader
icon={FolderPlus}
title="Dossiers d'inscriptions"
description="Gérez les dossiers d'inscription pour organiser vos documents."
button={true}
buttonOpeningModal={true}
onClick={() => setIsGroupModalOpen(true)}
/>
<Table
data={groups}
columns={columnsGroups}
emptyMessage={
<AlertMessage
type="warning"
title="Aucun dossier d'inscription enregistré"
message="Veuillez procéder à la création d'un nouveau dossier d'inscription"
/>
}
/>
</div>
{/* Section Formulaires */}
<div className="mt-12 mb-4 w-3/5">
<SectionHeader
icon={FileText}
title="Formulaires personnalisés"
description="Créez et gérez vos formulaires d'inscription personnalisés."
button={true}
buttonOpeningModal={true}
onClick={() => {
setIsModalOpen(true);
setIsEditing(false);
setFileToEdit(null);
}}
/>
<Table
data={filteredFiles}
columns={columnsFiles}
emptyMessage={
<AlertMessage
type="warning"
title="Aucun formulaire enregistré"
message="Veuillez procéder à la création d'un nouveau formulaire d'inscription"
/>
}
/>
</div>
{/* Section Pièces à fournir */}
<ParentFilesSection
parentFiles={parentFiles}
setParentFileMasters={setParentFileMasters}
groups={groups}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
<Popup
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}
/>
{isLoading && <Loader />}
</div>
);
}

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&aposinscription <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}

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",