mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-06-04 13:26:11 +00:00
Compare commits
5 Commits
79e14a23fe
...
90b0d14418
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b0d14418 | |||
| ae06b6fef7 | |||
| e37aee2abc | |||
| 2d678b732f | |||
| 4c56cb6474 |
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.contrib.auth.models
|
import django.contrib.auth.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class ProfileSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Profile
|
model = Profile
|
||||||
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault']
|
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault', 'first_name', 'last_name']
|
||||||
extra_kwargs = {'password': {'write_only': True}}
|
extra_kwargs = {'password': {'write_only': True}}
|
||||||
|
|
||||||
def get_roles(self, obj):
|
def get_roles(self, obj):
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -137,6 +137,12 @@ class ServeFileView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Nettoyer les prefixes media usuels si presents
|
||||||
|
if file_path.startswith('/media/'):
|
||||||
|
file_path = file_path[len('/media/'):]
|
||||||
|
elif file_path.startswith('media/'):
|
||||||
|
file_path = file_path[len('media/'):]
|
||||||
|
|
||||||
# Nettoyer le préfixe /data/ si présent
|
# Nettoyer le préfixe /data/ si présent
|
||||||
if file_path.startswith('/data/'):
|
if file_path.startswith('/data/'):
|
||||||
file_path = file_path[len('/data/'):]
|
file_path = file_path[len('/data/'):]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import Establishment.models
|
import Establishment.models
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import (
|
from .views import (
|
||||||
SendEmailView, search_recipients
|
SendEmailView, search_recipients, SendFeedbackView
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||||
|
path('send-feedback/', SendFeedbackView.as_view(), name='send_feedback'),
|
||||||
]
|
]
|
||||||
@ -5,6 +5,7 @@ from rest_framework import status
|
|||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.conf import settings
|
||||||
from Auth.models import Profile, ProfileRole
|
from Auth.models import Profile, ProfileRole
|
||||||
|
|
||||||
import N3wtSchool.mailManager as mailer
|
import N3wtSchool.mailManager as mailer
|
||||||
@ -119,3 +120,84 @@ def search_recipients(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return JsonResponse(results, safe=False)
|
return JsonResponse(results, safe=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SendFeedbackView(APIView):
|
||||||
|
"""
|
||||||
|
API pour envoyer un feedback au support (EMAIL_HOST_USER).
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
data = request.data
|
||||||
|
category = data.get('category', '')
|
||||||
|
subject = data.get('subject', 'Feedback')
|
||||||
|
message = data.get('message', '')
|
||||||
|
user_email = data.get('user_email', '')
|
||||||
|
user_name = data.get('user_name', '')
|
||||||
|
establishment = data.get('establishment', {})
|
||||||
|
|
||||||
|
logger.info(f"Feedback received - Category: {category}, Subject: {subject}")
|
||||||
|
|
||||||
|
if not message or not subject or not category:
|
||||||
|
return Response(
|
||||||
|
{'error': 'La catégorie, le sujet et le message sont requis.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Construire le message formaté
|
||||||
|
category_labels = {
|
||||||
|
'bug': 'Signalement de bug',
|
||||||
|
'feature': 'Proposition de fonctionnalité',
|
||||||
|
'question': 'Question',
|
||||||
|
'other': 'Autre'
|
||||||
|
}
|
||||||
|
category_label = category_labels.get(category, category)
|
||||||
|
|
||||||
|
# Construire les infos établissement
|
||||||
|
establishment_id = establishment.get('id', 'N/A')
|
||||||
|
establishment_name = establishment.get('name', 'N/A')
|
||||||
|
establishment_capacity = establishment.get('total_capacity', 'N/A')
|
||||||
|
establishment_frequency = establishment.get('evaluation_frequency', 'N/A')
|
||||||
|
|
||||||
|
formatted_message = f"""
|
||||||
|
<h2>Nouveau Feedback - {category_label}</h2>
|
||||||
|
<p><strong>De:</strong> {user_name} ({user_email})</p>
|
||||||
|
<h3>Établissement</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>ID:</strong> {establishment_id}</li>
|
||||||
|
<li><strong>Nom:</strong> {establishment_name}</li>
|
||||||
|
<li><strong>Capacité:</strong> {establishment_capacity}</li>
|
||||||
|
<li><strong>Fréquence d'évaluation:</strong> {establishment_frequency}</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<p><strong>Sujet:</strong> {subject}</p>
|
||||||
|
<div>
|
||||||
|
<strong>Message:</strong><br>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
formatted_subject = f"[N3WT School Feedback] [{category_label}] {subject}"
|
||||||
|
|
||||||
|
# Envoyer à EMAIL_HOST_USER avec la configuration SMTP par défaut
|
||||||
|
result = mailer.sendMail(
|
||||||
|
subject=formatted_subject,
|
||||||
|
message=formatted_message,
|
||||||
|
recipients=[settings.EMAIL_HOST_USER],
|
||||||
|
cc=[],
|
||||||
|
bcc=[],
|
||||||
|
attachments=[],
|
||||||
|
connection=None # Utilise la configuration SMTP par défaut
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Feedback envoyé avec succès")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de l'envoi du feedback: {str(e)}", exc_info=True)
|
||||||
|
return Response(
|
||||||
|
{'error': "Erreur lors de l'envoi du feedback"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
|
|||||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||||
('school_year', models.CharField(blank=True, max_length=9)),
|
('school_year', models.CharField(blank=True, max_length=9)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
('updated_date', models.DateTimeField(auto_now=True)),
|
('updated_date', models.DateTimeField(auto_now=True)),
|
||||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||||
('time_range', models.JSONField(default=list)),
|
('time_range', models.JSONField(default=list)),
|
||||||
@ -126,6 +127,26 @@ class Migration(migrations.Migration):
|
|||||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Evaluation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('period', models.CharField(help_text='Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026', max_length=20)),
|
||||||
|
('date', models.DateField(blank=True, null=True)),
|
||||||
|
('max_score', models.DecimalField(decimal_places=2, default=20, max_digits=5)),
|
||||||
|
('coefficient', models.DecimalField(decimal_places=2, default=1, max_digits=3)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='Establishment.establishment')),
|
||||||
|
('school_class', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.schoolclass')),
|
||||||
|
('speciality', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.speciality')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-date', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Teacher',
|
name='Teacher',
|
||||||
fields=[
|
fields=[
|
||||||
|
|||||||
@ -73,12 +73,15 @@ class SpecialityListCreateView(APIView):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
|
school_year = request.GET.get('school_year', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
specialities_list = getAllObjects(Speciality)
|
specialities_list = getAllObjects(Speciality)
|
||||||
if establishment_id:
|
if establishment_id:
|
||||||
specialities_list = specialities_list.filter(establishment__id=establishment_id).distinct()
|
specialities_list = specialities_list.filter(establishment__id=establishment_id).distinct()
|
||||||
|
if school_year:
|
||||||
|
specialities_list = specialities_list.filter(school_year=school_year)
|
||||||
specialities_serializer = SpecialitySerializer(specialities_list, many=True)
|
specialities_serializer = SpecialitySerializer(specialities_list, many=True)
|
||||||
return JsonResponse(specialities_serializer.data, safe=False)
|
return JsonResponse(specialities_serializer.data, safe=False)
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2026-04-04 09:15
|
||||||
|
|
||||||
import Subscriptions.models
|
import Subscriptions.models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
|
|||||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||||
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||||
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -90,6 +92,7 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
||||||
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
('last_update', models.DateTimeField(auto_now=True)),
|
('last_update', models.DateTimeField(auto_now=True)),
|
||||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||||
('notes', models.CharField(blank=True, max_length=200)),
|
('notes', models.CharField(blank=True, max_length=200)),
|
||||||
@ -209,6 +212,8 @@ class Migration(migrations.Migration):
|
|||||||
('score', models.IntegerField(blank=True, null=True)),
|
('score', models.IntegerField(blank=True, null=True)),
|
||||||
('comment', models.TextField(blank=True, null=True)),
|
('comment', models.TextField(blank=True, null=True)),
|
||||||
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||||
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
||||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
||||||
],
|
],
|
||||||
@ -216,4 +221,20 @@ class Migration(migrations.Migration):
|
|||||||
'unique_together': {('student', 'establishment_competency', 'period')},
|
'unique_together': {('student', 'establishment_competency', 'period')},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StudentEvaluation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
('is_absent', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('evaluation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.evaluation')),
|
||||||
|
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_scores', to='Subscriptions.student')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('student', 'evaluation')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -403,7 +403,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
|||||||
and isinstance(self.formMasterData, dict)
|
and isinstance(self.formMasterData, dict)
|
||||||
and self.formMasterData.get("fields")
|
and self.formMasterData.get("fields")
|
||||||
):
|
):
|
||||||
new_filename = f"{self.name}.pdf"
|
# Si un fichier source est déjà présent, conserver son extension.
|
||||||
|
extension = os.path.splitext(old_filename)[1] or '.pdf'
|
||||||
|
new_filename = f"{self.name}{extension}"
|
||||||
else:
|
else:
|
||||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||||
extension = os.path.splitext(old_filename)[1]
|
extension = os.path.splitext(old_filename)[1]
|
||||||
@ -438,16 +440,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
|||||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
# IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
|
||||||
if (
|
# rester le document source uploadé (PDF/image). La génération du PDF final
|
||||||
self.formMasterData
|
# est faite au niveau des templates (par élève), pas sur le master.
|
||||||
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -540,7 +535,8 @@ class RegistrationSchoolFileTemplate(models.Model):
|
|||||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||||
isValidated = models.BooleanField(default=False)
|
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||||
|
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@ -622,7 +618,8 @@ class RegistrationParentFileTemplate(models.Model):
|
|||||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||||
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||||
isValidated = models.BooleanField(default=False)
|
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||||
|
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||||
|
|||||||
@ -39,10 +39,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationSchoolFileMaster
|
model = RegistrationSchoolFileMaster
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
return obj.file.url if obj.file else None
|
||||||
|
|
||||||
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -52,6 +57,7 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
|||||||
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
file_url = serializers.SerializerMethodField()
|
file_url = serializers.SerializerMethodField()
|
||||||
|
master_file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationSchoolFileTemplate
|
model = RegistrationSchoolFileTemplate
|
||||||
@ -61,6 +67,12 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
|||||||
# Retourne l'URL complète du fichier si disponible
|
# Retourne l'URL complète du fichier si disponible
|
||||||
return obj.file.url if obj.file else None
|
return obj.file.url if obj.file else None
|
||||||
|
|
||||||
|
def get_master_file_url(self, obj):
|
||||||
|
# URL du fichier source du master (pour l'aperçu FileUpload côté parent)
|
||||||
|
if obj.master and obj.master.file:
|
||||||
|
return obj.master.file.url
|
||||||
|
return None
|
||||||
|
|
||||||
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
file_url = serializers.SerializerMethodField()
|
file_url = serializers.SerializerMethodField()
|
||||||
@ -399,10 +411,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
|
|||||||
class StudentByParentSerializer(serializers.ModelSerializer):
|
class StudentByParentSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.IntegerField(required=False)
|
id = serializers.IntegerField(required=False)
|
||||||
associated_class_name = serializers.SerializerMethodField()
|
associated_class_name = serializers.SerializerMethodField()
|
||||||
|
associated_class_id = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Student
|
model = Student
|
||||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name']
|
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name', 'associated_class_id']
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
||||||
@ -412,6 +425,9 @@ class StudentByParentSerializer(serializers.ModelSerializer):
|
|||||||
def get_associated_class_name(self, obj):
|
def get_associated_class_name(self, obj):
|
||||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||||
|
|
||||||
|
def get_associated_class_id(self, obj):
|
||||||
|
return obj.associated_class.id if obj.associated_class else None
|
||||||
|
|
||||||
class RegistrationFormByParentSerializer(serializers.ModelSerializer):
|
class RegistrationFormByParentSerializer(serializers.ModelSerializer):
|
||||||
student = StudentByParentSerializer(many=False, required=True)
|
student = StudentByParentSerializer(many=False, required=True)
|
||||||
|
|
||||||
|
|||||||
@ -1,228 +1,319 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
<title>
|
||||||
|
Fiche élève — {{ student.last_name }} {{ student.first_name }}
|
||||||
|
</title>
|
||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 2cm;
|
margin: 1.5cm 2cm;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: 'Arial', sans-serif;
|
font-family: "Helvetica", "Arial", sans-serif;
|
||||||
font-size: 12pt;
|
font-size: 10pt;
|
||||||
color: #222;
|
color: #1e293b;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
line-height: 1.4;
|
||||||
.container {
|
}
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
/* ── Header ── */
|
||||||
background: #fff;
|
.header-table {
|
||||||
}
|
width: 100%;
|
||||||
.header {
|
border: none;
|
||||||
text-align: center;
|
margin-bottom: 16px;
|
||||||
margin-bottom: 24px;
|
}
|
||||||
border-bottom: 2px solid #4CAF50;
|
.header-table td {
|
||||||
padding-bottom: 12px;
|
border: none;
|
||||||
position: relative;
|
padding: 0;
|
||||||
}
|
vertical-align: middle;
|
||||||
.title {
|
}
|
||||||
font-size: 22pt;
|
.header-left {
|
||||||
font-weight: bold;
|
width: 80%;
|
||||||
color: #4CAF50;
|
}
|
||||||
margin: 0;
|
.header-right {
|
||||||
}
|
width: 20%;
|
||||||
.photo {
|
text-align: right;
|
||||||
position: absolute;
|
}
|
||||||
top: 0;
|
.school-name {
|
||||||
right: 0;
|
font-size: 10pt;
|
||||||
width: 90px;
|
color: #64748b;
|
||||||
height: 90px;
|
margin: 0 0 4px 0;
|
||||||
object-fit: cover;
|
letter-spacing: 0.5px;
|
||||||
border: 1px solid #4CAF50;
|
}
|
||||||
border-radius: 8px;
|
.title {
|
||||||
}
|
font-size: 20pt;
|
||||||
.section {
|
font-weight: bold;
|
||||||
margin-bottom: 32px; /* Espacement augmenté entre les sections */
|
color: #064e3b;
|
||||||
}
|
margin: 0 0 2px 0;
|
||||||
.section-title {
|
}
|
||||||
font-size: 15pt;
|
.subtitle {
|
||||||
font-weight: bold;
|
font-size: 11pt;
|
||||||
color: #4CAF50;
|
color: #059669;
|
||||||
margin-bottom: 18px; /* Espacement sous le titre de section */
|
margin: 0;
|
||||||
border-bottom: 1px solid #4CAF50;
|
font-weight: normal;
|
||||||
padding-bottom: 2px;
|
}
|
||||||
}
|
.header-line {
|
||||||
table {
|
border: none;
|
||||||
width: 100%;
|
border-top: 3px solid #059669;
|
||||||
border-collapse: collapse;
|
margin: 12px 0 20px 0;
|
||||||
margin-bottom: 8px;
|
}
|
||||||
}
|
.photo {
|
||||||
th, td {
|
width: 80px;
|
||||||
border: 1px solid #bbb;
|
height: 80px;
|
||||||
padding: 6px 8px;
|
object-fit: cover;
|
||||||
text-align: left;
|
border: 2px solid #059669;
|
||||||
}
|
border-radius: 4px;
|
||||||
th {
|
}
|
||||||
background: #f3f3f3;
|
|
||||||
font-weight: bold;
|
/* ── Sections ── */
|
||||||
}
|
.section {
|
||||||
tr:nth-child(even) {
|
margin-bottom: 20px;
|
||||||
background: #fafafa;
|
}
|
||||||
}
|
.section-header {
|
||||||
.label-cell {
|
background-color: #059669;
|
||||||
font-weight: bold;
|
color: #ffffff;
|
||||||
width: 30%;
|
font-size: 11pt;
|
||||||
background: #f3f3f3;
|
font-weight: bold;
|
||||||
}
|
padding: 6px 12px;
|
||||||
.value-cell {
|
margin-bottom: 0;
|
||||||
width: 70%;
|
letter-spacing: 0.5px;
|
||||||
}
|
border-radius: 2px 2px 0 0;
|
||||||
.signature {
|
}
|
||||||
margin-top: 30px;
|
.subsection-title {
|
||||||
text-align: right;
|
font-size: 10pt;
|
||||||
font-style: italic;
|
color: #064e3b;
|
||||||
color: #555;
|
font-weight: bold;
|
||||||
}
|
padding: 6px 0 2px 0;
|
||||||
.signature-text {
|
margin: 8px 0 4px 0;
|
||||||
font-weight: bold;
|
border-bottom: 1px solid #d1d5db;
|
||||||
color: #333;
|
}
|
||||||
}
|
|
||||||
.subsection-title {
|
/* ── Tables ── */
|
||||||
font-size: 12pt;
|
table.data {
|
||||||
color: #333;
|
width: 100%;
|
||||||
margin: 8px 0 4px 0;
|
border-collapse: collapse;
|
||||||
font-weight: bold;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10pt;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
table.data .label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #064e3b;
|
||||||
|
background-color: #f0fdf4;
|
||||||
|
width: 25%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data .value {
|
||||||
|
color: #1e293b;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Paiement ── */
|
||||||
|
table.payment {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.payment td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
table.payment .label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #064e3b;
|
||||||
|
background-color: #f0fdf4;
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
table.payment .value {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer / Signature ── */
|
||||||
|
.signature-block {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.signature-block p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.signature-date {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #064e3b;
|
||||||
|
}
|
||||||
|
.footer-line {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid #059669;
|
||||||
|
margin: 20px 0 8px 0;
|
||||||
|
}
|
||||||
|
.footer-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% load myTemplateTag %}
|
{% load myTemplateTag %}
|
||||||
<div class="container">
|
|
||||||
<!-- Header Section -->
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
|
|
||||||
{% if student.photo %}
|
|
||||||
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" />
|
|
||||||
{% else %}
|
|
||||||
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" />
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Élève -->
|
<!-- ═══════ HEADER ═══════ -->
|
||||||
<div class="section">
|
<table class="header-table">
|
||||||
<div class="section-title">ÉLÈVE</div>
|
<tr>
|
||||||
<table>
|
<td class="header-left">
|
||||||
<tr>
|
{% if establishment %}
|
||||||
<td class="label-cell">Nom</td>
|
<p class="school-name">{{ establishment.name }}</p>
|
||||||
<td class="value-cell">{{ student.last_name }}</td>
|
{% endif %}
|
||||||
<td class="label-cell">Prénom</td>
|
<h1 class="title">Fiche Élèves</h1>
|
||||||
<td class="value-cell">{{ student.first_name }}</td>
|
<!-- prettier-ignore -->
|
||||||
</tr>
|
<p class="subtitle">{{ student.last_name }} {{ student.first_name }}{% if school_year %} — {{ school_year }}{% endif %}</p>
|
||||||
<tr>
|
</td>
|
||||||
<td class="label-cell">Adresse</td>
|
<td class="header-right">
|
||||||
<td class="value-cell" colspan="3">{{ student.address }}</td>
|
{% if student.photo %}
|
||||||
</tr>
|
<img src="{{ student.get_photo_url }}" alt="Photo" class="photo" />
|
||||||
<tr>
|
{% endif %}
|
||||||
<td class="label-cell">Genre</td>
|
</td>
|
||||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
</tr>
|
||||||
<td class="label-cell">Né(e) le</td>
|
</table>
|
||||||
<td class="value-cell">{{ student.birth_date }}</td>
|
<hr class="header-line" />
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">À</td>
|
|
||||||
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
|
|
||||||
<td class="label-cell">Nationalité</td>
|
|
||||||
<td class="value-cell">{{ student.nationality }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">Niveau</td>
|
|
||||||
<td class="value-cell">{{ student|getStudentLevel }}</td>
|
|
||||||
<td class="label-cell"></td>
|
|
||||||
<td class="value-cell"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Responsables -->
|
<!-- ═══════ ÉLÈVE ═══════ -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">RESPONSABLES</div>
|
<div class="section-header">INFORMATIONS DE L'ÉLÈVE</div>
|
||||||
{% for guardian in student.getGuardians %}
|
<table class="data">
|
||||||
<div>
|
<tr>
|
||||||
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
<td class="label">Nom</td>
|
||||||
<table>
|
<td class="value">{{ student.last_name }}</td>
|
||||||
<tr>
|
<td class="label">Prénom</td>
|
||||||
<td class="label-cell">Nom</td>
|
<td class="value">{{ student.first_name }}</td>
|
||||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
</tr>
|
||||||
<td class="label-cell">Prénom</td>
|
<tr>
|
||||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
<td class="label">Genre</td>
|
||||||
</tr>
|
<td class="value">{{ student|getStudentGender }}</td>
|
||||||
<tr>
|
<td class="label">Niveau</td>
|
||||||
<td class="label-cell">Adresse</td>
|
<td class="value">{{ student|getStudentLevel }}</td>
|
||||||
<td class="value-cell" colspan="3">{{ guardian.address }}</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr>
|
<td class="label">Date de naissance</td>
|
||||||
<td class="label-cell">Email</td>
|
<td class="value">{{ student.formatted_birth_date }}</td>
|
||||||
<td class="value-cell" colspan="3">{{ guardian.email }}</td>
|
<td class="label">Lieu de naissance</td>
|
||||||
</tr>
|
<!-- prettier-ignore -->
|
||||||
<tr>
|
<td class="value">{{ student.birth_place }}{% if student.birth_postal_code %} ({{ student.birth_postal_code }}){% endif %}</td>
|
||||||
<td class="label-cell">Né(e) le</td>
|
</tr>
|
||||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
<tr>
|
||||||
<td class="label-cell">Téléphone</td>
|
<td class="label">Nationalité</td>
|
||||||
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
|
<td class="value">{{ student.nationality }}</td>
|
||||||
</tr>
|
<td class="label">Médecin traitant</td>
|
||||||
<tr>
|
<td class="value">{{ student.attending_physician }}</td>
|
||||||
<td class="label-cell">Profession</td>
|
</tr>
|
||||||
<td class="value-cell" colspan="3">{{ guardian.profession }}</td>
|
<tr>
|
||||||
</tr>
|
<td class="label">Adresse</td>
|
||||||
</table>
|
<td class="value" colspan="3">{{ student.address }}</td>
|
||||||
</div>
|
</tr>
|
||||||
{% endfor %}
|
</table>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fratrie -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">FRATRIE</div>
|
|
||||||
{% for sibling in student.getSiblings %}
|
|
||||||
<div>
|
|
||||||
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">Nom</td>
|
|
||||||
<td class="value-cell">{{ sibling.last_name }}</td>
|
|
||||||
<td class="label-cell">Prénom</td>
|
|
||||||
<td class="value-cell">{{ sibling.first_name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">Né(e) le</td>
|
|
||||||
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paiement -->
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">MODALITÉS DE PAIEMENT</div>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">Frais d'inscription</td>
|
|
||||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="label-cell">Frais de scolarité</td>
|
|
||||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signature -->
|
|
||||||
<div class="signature">
|
|
||||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
</html>
|
<!-- ═══════ RESPONSABLES ═══════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">RESPONSABLES LÉGAUX</div>
|
||||||
|
{% for guardian in student.getGuardians %}
|
||||||
|
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
||||||
|
<table class="data">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Nom</td>
|
||||||
|
<td class="value">{{ guardian.last_name }}</td>
|
||||||
|
<td class="label">Prénom</td>
|
||||||
|
<td class="value">{{ guardian.first_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Date de naissance</td>
|
||||||
|
<td class="value">{{ guardian.birth_date }}</td>
|
||||||
|
<td class="label">Téléphone</td>
|
||||||
|
<td class="value">{{ guardian.phone|phone_format }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Email</td>
|
||||||
|
<td class="value" colspan="3">{{ guardian.email }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Adresse</td>
|
||||||
|
<td class="value" colspan="3">{{ guardian.address }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Profession</td>
|
||||||
|
<td class="value" colspan="3">{{ guardian.profession }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% empty %}
|
||||||
|
<p style="color: #94a3b8; font-style: italic; padding: 8px">
|
||||||
|
Aucun responsable renseigné.
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════ FRATRIE ═══════ -->
|
||||||
|
{% if student.getSiblings %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">FRATRIE</div>
|
||||||
|
{% for sibling in student.getSiblings %}
|
||||||
|
<div class="subsection-title">Frère / Sœur {{ forloop.counter }}</div>
|
||||||
|
<table class="data">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Nom</td>
|
||||||
|
<td class="value">{{ sibling.last_name }}</td>
|
||||||
|
<td class="label">Prénom</td>
|
||||||
|
<td class="value">{{ sibling.first_name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Date de naissance</td>
|
||||||
|
<td class="value" colspan="3">{{ sibling.birth_date }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ═══════ PAIEMENT ═══════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">MODALITÉS DE PAIEMENT</div>
|
||||||
|
<table class="payment">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Frais d'inscription</td>
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
<td class="value">{{ student|getRegistrationPaymentMethod }} — {{ student|getRegistrationPaymentPlan }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Frais de scolarité</td>
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
<td class="value">{{ student|getTuitionPaymentMethod }} — {{ student|getTuitionPaymentPlan }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════ SIGNATURE ═══════ -->
|
||||||
|
<div class="signature-block">
|
||||||
|
<p>
|
||||||
|
Document généré le
|
||||||
|
<span class="signature-date">{{ signatureDate }}</span> à
|
||||||
|
<span class="signature-date">{{ signatureTime }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="footer-line" />
|
||||||
|
<p class="footer-text">
|
||||||
|
Ce document est généré automatiquement et fait office de fiche
|
||||||
|
d'inscription.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -3,16 +3,16 @@ from django.urls import path, re_path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
# RF
|
# RF
|
||||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive
|
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, generate_registration_pdf
|
||||||
# SubClasses
|
# SubClasses
|
||||||
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
|
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
|
||||||
# Files
|
# Files
|
||||||
from .views import (
|
from .views import (
|
||||||
RegistrationSchoolFileMasterView,
|
RegistrationSchoolFileMasterView,
|
||||||
RegistrationSchoolFileMasterSimpleView,
|
RegistrationSchoolFileMasterSimpleView,
|
||||||
RegistrationSchoolFileTemplateView,
|
RegistrationSchoolFileTemplateView,
|
||||||
RegistrationSchoolFileTemplateSimpleView,
|
RegistrationSchoolFileTemplateSimpleView,
|
||||||
RegistrationParentFileMasterSimpleView,
|
RegistrationParentFileMasterSimpleView,
|
||||||
RegistrationParentFileMasterView,
|
RegistrationParentFileMasterView,
|
||||||
RegistrationParentFileTemplateSimpleView,
|
RegistrationParentFileTemplateSimpleView,
|
||||||
RegistrationParentFileTemplateView,
|
RegistrationParentFileTemplateView,
|
||||||
@ -25,11 +25,12 @@ from .views import (
|
|||||||
|
|
||||||
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
||||||
from .views import (
|
from .views import (
|
||||||
get_school_file_templates_by_rf,
|
get_school_file_templates_by_rf,
|
||||||
get_parent_file_templates_by_rf
|
get_parent_file_templates_by_rf
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
re_path(r'^registerForms/(?P<id>[0-9]+)/pdf$', generate_registration_pdf, name="generate_registration_pdf"),
|
||||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||||
re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"),
|
re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"),
|
||||||
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),
|
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),
|
||||||
@ -52,7 +53,7 @@ urlpatterns = [
|
|||||||
re_path(r'^registrationFileGroups/(?P<id>[0-9]+)$', RegistrationFileGroupSimpleView.as_view(), name='registrationFileGroupDetail'),
|
re_path(r'^registrationFileGroups/(?P<id>[0-9]+)$', RegistrationFileGroupSimpleView.as_view(), name='registrationFileGroupDetail'),
|
||||||
re_path(r'^registrationFileGroups/(?P<id>[0-9]+)/templates$', get_registration_files_by_group, name="get_registration_files_by_group"),
|
re_path(r'^registrationFileGroups/(?P<id>[0-9]+)/templates$', get_registration_files_by_group, name="get_registration_files_by_group"),
|
||||||
re_path(r'^registrationFileGroups$', RegistrationFileGroupView.as_view(), name='registrationFileGroups'),
|
re_path(r'^registrationFileGroups$', RegistrationFileGroupView.as_view(), name='registrationFileGroups'),
|
||||||
|
|
||||||
re_path(r'^registrationSchoolFileMasters/(?P<id>[0-9]+)$', RegistrationSchoolFileMasterSimpleView.as_view(), name='registrationSchoolFileMasters'),
|
re_path(r'^registrationSchoolFileMasters/(?P<id>[0-9]+)$', RegistrationSchoolFileMasterSimpleView.as_view(), name='registrationSchoolFileMasters'),
|
||||||
re_path(r'^registrationSchoolFileMasters$', RegistrationSchoolFileMasterView.as_view(), name='registrationSchoolFileMasters'),
|
re_path(r'^registrationSchoolFileMasters$', RegistrationSchoolFileMasterView.as_view(), name='registrationSchoolFileMasters'),
|
||||||
|
|
||||||
|
|||||||
@ -8,18 +8,22 @@ from N3wtSchool import renderers
|
|||||||
from N3wtSchool import bdd
|
from N3wtSchool import bdd
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
import base64
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.graphics import renderPDF
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from urllib.parse import unquote_to_bytes
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser
|
||||||
from PyPDF2 import PdfMerger, PdfReader
|
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||||
from PyPDF2.errors import PdfReadError
|
from PyPDF2.errors import PdfReadError
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
@ -29,14 +33,84 @@ import json
|
|||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from svglib.svglib import svg2rlg
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_signature_data_url(cnv, data_url, x, y, width, height):
|
||||||
|
"""
|
||||||
|
Dessine une signature issue d'un data URL dans un canvas ReportLab.
|
||||||
|
Supporte les images raster (PNG/JPEG/...) et SVG.
|
||||||
|
Retourne True si la signature a pu etre dessinee.
|
||||||
|
"""
|
||||||
|
if not isinstance(data_url, str) or not data_url.startswith("data:image"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
header, payload = data_url.split(',', 1)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_base64 = ';base64' in header
|
||||||
|
mime_type = header.split(':', 1)[1].split(';', 1)[0] if ':' in header else ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_bytes = base64.b64decode(payload) if is_base64 else unquote_to_bytes(payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Decodage impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Support SVG via svglib (deja present dans requirements)
|
||||||
|
if mime_type == 'image/svg+xml':
|
||||||
|
try:
|
||||||
|
drawing = svg2rlg(BytesIO(raw_bytes))
|
||||||
|
if drawing is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
src_w = float(getattr(drawing, 'width', 0) or 0)
|
||||||
|
src_h = float(getattr(drawing, 'height', 0) or 0)
|
||||||
|
if src_w <= 0 or src_h <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
scale = min(width / src_w, height / src_h)
|
||||||
|
draw_w = src_w * scale
|
||||||
|
draw_h = src_h * scale
|
||||||
|
offset_x = x + (width - draw_w) / 2
|
||||||
|
offset_y = y + (height - draw_h) / 2
|
||||||
|
|
||||||
|
cnv.saveState()
|
||||||
|
cnv.translate(offset_x, offset_y)
|
||||||
|
cnv.scale(scale, scale)
|
||||||
|
renderPDF.draw(drawing, cnv, 0, 0)
|
||||||
|
cnv.restoreState()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Rendu SVG impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Support images raster classiques
|
||||||
|
try:
|
||||||
|
img_reader = ImageReader(BytesIO(raw_bytes))
|
||||||
|
cnv.drawImage(
|
||||||
|
img_reader,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
preserveAspectRatio=True,
|
||||||
|
mask='auto',
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[_draw_signature_data_url] Rendu raster impossible: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def save_file_replacing_existing(file_field, filename, content, save=True):
|
def save_file_replacing_existing(file_field, filename, content, save=True):
|
||||||
"""
|
"""
|
||||||
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
||||||
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
|
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_field: Le FileField Django (ex: registerForm.registration_file)
|
file_field: Le FileField Django (ex: registerForm.registration_file)
|
||||||
filename: Le nom du fichier à sauvegarder
|
filename: Le nom du fichier à sauvegarder
|
||||||
@ -51,10 +125,46 @@ def save_file_replacing_existing(file_field, filename, content, save=True):
|
|||||||
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
|
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
|
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
|
||||||
|
|
||||||
# Sauvegarder le nouveau fichier
|
# Sauvegarder le nouveau fichier
|
||||||
file_field.save(filename, content, save=save)
|
file_field.save(filename, content, save=save)
|
||||||
|
|
||||||
|
def save_file_field_without_suffix(instance, field_name, filename, content, save=False):
|
||||||
|
"""
|
||||||
|
Sauvegarde un fichier dans un FileField Django en ecrasant le precedent,
|
||||||
|
sans laisser Django generer de suffixe (_abc123).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: instance Django portant le FileField
|
||||||
|
field_name: nom du FileField (ex: 'file')
|
||||||
|
filename: nom de fichier cible (basename)
|
||||||
|
content: contenu fichier (ContentFile, File, etc.)
|
||||||
|
save: si True, persiste immediatement l'instance
|
||||||
|
"""
|
||||||
|
file_field = getattr(instance, field_name)
|
||||||
|
field = instance._meta.get_field(field_name)
|
||||||
|
storage = file_field.storage
|
||||||
|
|
||||||
|
target_name = field.generate_filename(instance, filename)
|
||||||
|
|
||||||
|
# Supprimer le fichier actuellement reference si different
|
||||||
|
if file_field and file_field.name and file_field.name != target_name:
|
||||||
|
try:
|
||||||
|
if storage.exists(file_field.name):
|
||||||
|
storage.delete(file_field.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[save_file_field_without_suffix] Erreur suppression ancien fichier ({file_field.name}): {e}")
|
||||||
|
|
||||||
|
# Supprimer explicitement la cible si elle existe deja
|
||||||
|
try:
|
||||||
|
if storage.exists(target_name):
|
||||||
|
storage.delete(target_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[save_file_field_without_suffix] Erreur suppression cible ({target_name}): {e}")
|
||||||
|
|
||||||
|
# Sauvegarde: la cible n'existe plus, donc pas de suffixe
|
||||||
|
file_field.save(filename, content, save=save)
|
||||||
|
|
||||||
def build_payload_from_request(request):
|
def build_payload_from_request(request):
|
||||||
"""
|
"""
|
||||||
Normalise la request en payload prêt à être donné au serializer.
|
Normalise la request en payload prêt à être donné au serializer.
|
||||||
@ -194,6 +304,91 @@ def create_templates_for_registration_form(register_form):
|
|||||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||||
|
|
||||||
|
is_dynamic_master = (
|
||||||
|
isinstance(m.formMasterData, dict)
|
||||||
|
and bool(m.formMasterData.get("fields"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Formulaire dynamique: toujours générer le PDF final depuis le JSON
|
||||||
|
# (aperçu admin) au lieu de copier le fichier source brut (PNG/PDF).
|
||||||
|
if is_dynamic_master:
|
||||||
|
base_pdf_content = None
|
||||||
|
base_file_ext = None
|
||||||
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
|
base_file_ext = os.path.splitext(m.file.name)[1].lower()
|
||||||
|
try:
|
||||||
|
m.file.open('rb')
|
||||||
|
base_pdf_content = m.file.read()
|
||||||
|
m.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source master dynamique: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
generated_pdf = generate_form_json_pdf(
|
||||||
|
register_form,
|
||||||
|
m.formMasterData,
|
||||||
|
base_pdf_content=base_pdf_content,
|
||||||
|
base_file_ext=base_file_ext,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur génération PDF dynamique pour template: {e}")
|
||||||
|
generated_pdf = None
|
||||||
|
|
||||||
|
if tmpl:
|
||||||
|
try:
|
||||||
|
if tmpl.file and tmpl.file.name:
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression ancien fichier template dynamique %s", getattr(tmpl, "pk", None))
|
||||||
|
|
||||||
|
tmpl.name = m.name or ""
|
||||||
|
tmpl.slug = slug
|
||||||
|
tmpl.formTemplateData = m.formMasterData or []
|
||||||
|
if generated_pdf is not None:
|
||||||
|
output_filename = os.path.basename(generated_pdf.name)
|
||||||
|
save_file_field_without_suffix(
|
||||||
|
tmpl,
|
||||||
|
'file',
|
||||||
|
output_filename,
|
||||||
|
generated_pdf,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info(
|
||||||
|
"util.create_templates_for_registration_form - Regenerated dynamic school template %s from master %s for RF %s",
|
||||||
|
tmpl.pk,
|
||||||
|
m.pk,
|
||||||
|
register_form.pk,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmpl = RegistrationSchoolFileTemplate(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
name=m.name or "",
|
||||||
|
formTemplateData=m.formMasterData or [],
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
if generated_pdf is not None:
|
||||||
|
output_filename = os.path.basename(generated_pdf.name)
|
||||||
|
save_file_field_without_suffix(
|
||||||
|
tmpl,
|
||||||
|
'file',
|
||||||
|
output_filename,
|
||||||
|
generated_pdf,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info(
|
||||||
|
"util.create_templates_for_registration_form - Created dynamic school template %s from master %s for RF %s",
|
||||||
|
tmpl.pk,
|
||||||
|
m.pk,
|
||||||
|
register_form.pk,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
file_name = os.path.basename(m.file.name)
|
file_name = os.path.basename(m.file.name)
|
||||||
@ -207,19 +402,21 @@ def create_templates_for_registration_form(register_form):
|
|||||||
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||||
file_name = None
|
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
|
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||||
|
|
||||||
|
def _build_upload_path(template_pk):
|
||||||
|
"""Génère le chemin relatif et absolu pour un template avec un pk connu."""
|
||||||
|
rel = registration_school_file_upload_to(
|
||||||
|
type("Tmp", (), {
|
||||||
|
"registration_form": register_form,
|
||||||
|
"pk": template_pk,
|
||||||
|
})(),
|
||||||
|
file_name,
|
||||||
|
)
|
||||||
|
return rel, os.path.join(settings.MEDIA_ROOT, rel)
|
||||||
|
|
||||||
if tmpl:
|
if tmpl:
|
||||||
|
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||||
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
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
|
master_file_changed = template_file_name != file_name
|
||||||
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||||
@ -254,7 +451,7 @@ def create_templates_for_registration_form(register_form):
|
|||||||
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)
|
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
|
continue
|
||||||
|
|
||||||
# Sinon, création du template comme avant
|
# Sinon, création du template — sauvegarder d'abord pour obtenir un pk
|
||||||
tmpl = RegistrationSchoolFileTemplate(
|
tmpl = RegistrationSchoolFileTemplate(
|
||||||
master=m,
|
master=m,
|
||||||
registration_form=register_form,
|
registration_form=register_form,
|
||||||
@ -262,8 +459,10 @@ def create_templates_for_registration_form(register_form):
|
|||||||
formTemplateData=m.formMasterData or [],
|
formTemplateData=m.formMasterData or [],
|
||||||
slug=slug,
|
slug=slug,
|
||||||
)
|
)
|
||||||
|
tmpl.save() # pk attribué ici
|
||||||
if file_name:
|
if file_name:
|
||||||
# Copier le fichier du master si besoin (form existant)
|
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||||
|
# Copier le fichier du master si besoin
|
||||||
if master_file_path and not os.path.exists(abs_path):
|
if master_file_path and not os.path.exists(abs_path):
|
||||||
try:
|
try:
|
||||||
import shutil
|
import shutil
|
||||||
@ -273,7 +472,7 @@ def create_templates_for_registration_form(register_form):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
||||||
tmpl.file.name = upload_rel_path
|
tmpl.file.name = upload_rel_path
|
||||||
tmpl.save()
|
tmpl.save()
|
||||||
created.append(tmpl)
|
created.append(tmpl)
|
||||||
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
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)
|
||||||
|
|
||||||
@ -453,6 +652,8 @@ def rfToPDF(registerForm, filename):
|
|||||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||||
'student': registerForm.student,
|
'student': registerForm.student,
|
||||||
|
'establishment': registerForm.establishment,
|
||||||
|
'school_year': registerForm.school_year,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Générer le PDF
|
# Générer le PDF
|
||||||
@ -474,6 +675,24 @@ def rfToPDF(registerForm, filename):
|
|||||||
|
|
||||||
return registerForm.registration_file
|
return registerForm.registration_file
|
||||||
|
|
||||||
|
def generateRegistrationPDF(registerForm):
|
||||||
|
"""
|
||||||
|
Génère le PDF d'un dossier d'inscription à la volée et retourne le contenu binaire.
|
||||||
|
Ne sauvegarde pas le fichier sur disque.
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}",
|
||||||
|
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||||
|
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||||
|
'student': registerForm.student,
|
||||||
|
'establishment': registerForm.establishment,
|
||||||
|
'school_year': registerForm.school_year,
|
||||||
|
}
|
||||||
|
pdf = renderers.render_to_pdf('pdfs/fiche_eleve.html', data)
|
||||||
|
if not pdf:
|
||||||
|
raise ValueError("Erreur lors de la génération du PDF.")
|
||||||
|
return pdf.content
|
||||||
|
|
||||||
def delete_registration_files(registerForm):
|
def delete_registration_files(registerForm):
|
||||||
"""
|
"""
|
||||||
Supprime le fichier et le dossier associés à un RegistrationForm.
|
Supprime le fichier et le dossier associés à un RegistrationForm.
|
||||||
@ -527,55 +746,196 @@ def getHistoricalYears(count=5):
|
|||||||
|
|
||||||
return historical_years
|
return historical_years
|
||||||
|
|
||||||
def generate_form_json_pdf(register_form, form_json):
|
def generate_form_json_pdf(register_form, form_json, base_pdf_content=None, base_file_ext=None, base_pdf_path=None):
|
||||||
"""
|
"""
|
||||||
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
Génère un PDF composite du formulaire dynamique:
|
||||||
et l'associe au RegistrationSchoolFileTemplate.
|
- le document source uploadé (PDF/image) si présent,
|
||||||
Le PDF contient le titre, les labels et types de champs.
|
- puis un rendu du formulaire (similaire à l'aperçu),
|
||||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
- avec overlay de signature(s) sur la dernière page du document source.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Récupérer le nom du formulaire
|
|
||||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||||
filename = f"{form_name}.pdf"
|
filename = f"{form_name}.pdf"
|
||||||
|
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
|
||||||
|
|
||||||
# Générer le PDF
|
# Compatibilité ascendante : charger depuis un chemin si nécessaire
|
||||||
buffer = BytesIO()
|
if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
|
||||||
c = canvas.Canvas(buffer, pagesize=A4)
|
base_file_ext = os.path.splitext(base_pdf_path)[1].lower()
|
||||||
y = 800
|
try:
|
||||||
|
with open(base_pdf_path, 'rb') as f:
|
||||||
|
base_pdf_content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Lecture fichier source: {e}")
|
||||||
|
|
||||||
# Titre
|
writer = PdfWriter()
|
||||||
c.setFont("Helvetica-Bold", 20)
|
has_source_document = False
|
||||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
source_is_image = False
|
||||||
y -= 40
|
source_image_reader = None
|
||||||
|
source_image_size = None
|
||||||
|
|
||||||
# Champs
|
# 1) Charger le document source (PDF/image) si présent
|
||||||
c.setFont("Helvetica", 12)
|
if base_pdf_content:
|
||||||
fields = form_json.get("fields", [])
|
try:
|
||||||
|
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'}
|
||||||
|
ext = (base_file_ext or '').lower()
|
||||||
|
|
||||||
|
if ext in image_exts:
|
||||||
|
# Pour les images, on les rend dans la section [fichier uploade]
|
||||||
|
# au lieu de les ajouter comme page source separee.
|
||||||
|
img_buffer = BytesIO(base_pdf_content)
|
||||||
|
source_image_reader = ImageReader(img_buffer)
|
||||||
|
source_image_size = source_image_reader.getSize()
|
||||||
|
source_is_image = True
|
||||||
|
else:
|
||||||
|
source_reader = PdfReader(BytesIO(base_pdf_content))
|
||||||
|
for page in source_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
has_source_document = len(source_reader.pages) > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur chargement source: {e}")
|
||||||
|
|
||||||
|
# 2) Overlay des signatures sur la dernière page du document source
|
||||||
|
# Desactive ici pour eviter les doublons: la signature est rendue
|
||||||
|
# dans la section JSON du formulaire (et non plus en overlay source).
|
||||||
|
signatures = []
|
||||||
for field in fields:
|
for field in fields:
|
||||||
label = field.get("label", field.get("id", ""))
|
if field.get("type") == "signature":
|
||||||
ftype = field.get("type", "")
|
value = field.get("value")
|
||||||
value = field.get("value", "")
|
if isinstance(value, str) and value.startswith("data:image"):
|
||||||
# Afficher la valeur si elle existe
|
signatures.append(value)
|
||||||
if value not in (None, ""):
|
|
||||||
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
|
||||||
else:
|
|
||||||
c.drawString(100, y, f"{label} [{ftype}]")
|
|
||||||
y -= 25
|
|
||||||
if y < 100:
|
|
||||||
c.showPage()
|
|
||||||
y = 800
|
|
||||||
|
|
||||||
c.save()
|
enable_source_signature_overlay = False
|
||||||
buffer.seek(0)
|
if signatures and len(writer.pages) > 0 and enable_source_signature_overlay:
|
||||||
pdf_content = buffer.read()
|
try:
|
||||||
|
target_page = writer.pages[len(writer.pages) - 1]
|
||||||
|
page_width = float(target_page.mediabox.width)
|
||||||
|
page_height = float(target_page.mediabox.height)
|
||||||
|
|
||||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
packet = BytesIO()
|
||||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height))
|
||||||
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
|
sig_width = 170
|
||||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
sig_height = 70
|
||||||
|
margin = 36
|
||||||
|
spacing = 10
|
||||||
|
|
||||||
|
for i, data_url in enumerate(signatures[:3]):
|
||||||
|
try:
|
||||||
|
x = page_width - sig_width - margin
|
||||||
|
y = margin + i * (sig_height + spacing)
|
||||||
|
_draw_signature_data_url(c_overlay, data_url, x, y, sig_width, sig_height)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Signature ignorée: {e}")
|
||||||
|
|
||||||
|
c_overlay.save()
|
||||||
|
packet.seek(0)
|
||||||
|
overlay_pdf = PdfReader(packet)
|
||||||
|
if overlay_pdf.pages:
|
||||||
|
target_page.merge_page(overlay_pdf.pages[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur overlay signature: {e}")
|
||||||
|
|
||||||
|
# 3) Rendu JSON explicite du formulaire final (toujours genere).
|
||||||
|
# Cela garantit la presence des sections H1 / FileUpload / Signature
|
||||||
|
# dans le PDF final, meme si un document source est fourni.
|
||||||
|
fields_to_render = fields
|
||||||
|
|
||||||
|
if fields_to_render:
|
||||||
|
layout_buffer = BytesIO()
|
||||||
|
c = canvas.Canvas(layout_buffer, pagesize=A4)
|
||||||
|
y = 800
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 18)
|
||||||
|
c.drawString(60, y, form_json.get("title", "Formulaire"))
|
||||||
|
y -= 35
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
for field in fields_to_render:
|
||||||
|
ftype = field.get("type", "")
|
||||||
|
if ftype in {"heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "paragraph"}:
|
||||||
|
text = field.get("text", "")
|
||||||
|
if text:
|
||||||
|
c.setFont("Helvetica-Bold" if ftype.startswith("heading") else "Helvetica", 11)
|
||||||
|
c.drawString(60, y, text[:120])
|
||||||
|
y -= 18
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = field.get("label", field.get("id", "Champ"))
|
||||||
|
value = field.get("value", "")
|
||||||
|
|
||||||
|
if ftype == "file":
|
||||||
|
c.drawString(60, y, f"{label}")
|
||||||
|
y -= 18
|
||||||
|
|
||||||
|
if source_is_image and source_image_reader and source_image_size:
|
||||||
|
img_w, img_h = source_image_size
|
||||||
|
max_w = 420
|
||||||
|
max_h = 260
|
||||||
|
ratio = min(max_w / img_w, max_h / img_h)
|
||||||
|
draw_w = img_w * ratio
|
||||||
|
draw_h = img_h * ratio
|
||||||
|
|
||||||
|
if y - draw_h < 80:
|
||||||
|
c.showPage()
|
||||||
|
y = 800
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
|
||||||
|
c.drawImage(
|
||||||
|
source_image_reader,
|
||||||
|
60,
|
||||||
|
y - draw_h,
|
||||||
|
width=draw_w,
|
||||||
|
height=draw_h,
|
||||||
|
preserveAspectRatio=True,
|
||||||
|
mask='auto',
|
||||||
|
)
|
||||||
|
y -= draw_h + 14
|
||||||
|
elif ftype == "signature":
|
||||||
|
c.drawString(60, y, f"{label}")
|
||||||
|
sig_drawn = False
|
||||||
|
if isinstance(value, str) and value.startswith("data:image"):
|
||||||
|
try:
|
||||||
|
sig_drawn = _draw_signature_data_url(c, value, 260, y - 55, 170, 55)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Signature render echoue: {e}")
|
||||||
|
if not sig_drawn:
|
||||||
|
c.rect(260, y - 55, 170, 55)
|
||||||
|
y -= 70
|
||||||
|
else:
|
||||||
|
if value not in (None, ""):
|
||||||
|
c.drawString(60, y, f"{label} [{ftype}] : {str(value)[:120]}")
|
||||||
|
else:
|
||||||
|
c.drawString(60, y, f"{label} [{ftype}]")
|
||||||
|
y -= 18
|
||||||
|
|
||||||
|
if y < 80:
|
||||||
|
c.showPage()
|
||||||
|
y = 800
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
layout_buffer.seek(0)
|
||||||
|
try:
|
||||||
|
layout_reader = PdfReader(layout_buffer)
|
||||||
|
for page in layout_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[generate_form_json_pdf] Erreur ajout rendu formulaire: {e}")
|
||||||
|
|
||||||
|
# 4) Fallback minimal si aucune page n'a été créée
|
||||||
|
if len(writer.pages) == 0:
|
||||||
|
fallback = BytesIO()
|
||||||
|
c_fb = canvas.Canvas(fallback, pagesize=A4)
|
||||||
|
c_fb.setFont("Helvetica-Bold", 16)
|
||||||
|
c_fb.drawString(60, 800, form_json.get("title", "Formulaire"))
|
||||||
|
c_fb.save()
|
||||||
|
fallback.seek(0)
|
||||||
|
fallback_reader = PdfReader(fallback)
|
||||||
|
for page in fallback_reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
writer.write(out)
|
||||||
|
out.seek(0)
|
||||||
|
return ContentFile(out.read(), name=os.path.basename(filename))
|
||||||
|
|||||||
@ -1,22 +1,23 @@
|
|||||||
from .register_form_views import (
|
from .register_form_views import (
|
||||||
RegisterFormView,
|
RegisterFormView,
|
||||||
RegisterFormWithIdView,
|
RegisterFormWithIdView,
|
||||||
send,
|
send,
|
||||||
resend,
|
resend,
|
||||||
archive,
|
archive,
|
||||||
get_school_file_templates_by_rf,
|
get_school_file_templates_by_rf,
|
||||||
get_parent_file_templates_by_rf
|
get_parent_file_templates_by_rf,
|
||||||
|
generate_registration_pdf
|
||||||
)
|
)
|
||||||
from .registration_school_file_masters_views import (
|
from .registration_school_file_masters_views import (
|
||||||
RegistrationSchoolFileMasterView,
|
RegistrationSchoolFileMasterView,
|
||||||
RegistrationSchoolFileMasterSimpleView,
|
RegistrationSchoolFileMasterSimpleView,
|
||||||
)
|
)
|
||||||
from .registration_school_file_templates_views import (
|
from .registration_school_file_templates_views import (
|
||||||
RegistrationSchoolFileTemplateView,
|
RegistrationSchoolFileTemplateView,
|
||||||
RegistrationSchoolFileTemplateSimpleView
|
RegistrationSchoolFileTemplateSimpleView
|
||||||
)
|
)
|
||||||
from .registration_parent_file_masters_views import (
|
from .registration_parent_file_masters_views import (
|
||||||
RegistrationParentFileMasterView,
|
RegistrationParentFileMasterView,
|
||||||
RegistrationParentFileMasterSimpleView
|
RegistrationParentFileMasterSimpleView
|
||||||
)
|
)
|
||||||
from .registration_parent_file_templates_views import (
|
from .registration_parent_file_templates_views import (
|
||||||
@ -48,6 +49,7 @@ __all__ = [
|
|||||||
'get_registration_files_by_group',
|
'get_registration_files_by_group',
|
||||||
'get_school_file_templates_by_rf',
|
'get_school_file_templates_by_rf',
|
||||||
'get_parent_file_templates_by_rf',
|
'get_parent_file_templates_by_rf',
|
||||||
|
'generate_registration_pdf',
|
||||||
'StudentView',
|
'StudentView',
|
||||||
'StudentListView',
|
'StudentListView',
|
||||||
'ChildrenListView',
|
'ChildrenListView',
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from django.http.response import JsonResponse
|
from django.http.response import JsonResponse
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@ -365,7 +366,7 @@ class RegisterFormWithIdView(APIView):
|
|||||||
student = registerForm.student
|
student = registerForm.student
|
||||||
student_name = f"{student.first_name} {student.last_name}"
|
student_name = f"{student.first_name} {student.last_name}"
|
||||||
notes = registerForm.notes or "Aucun motif spécifié"
|
notes = registerForm.notes or "Aucun motif spécifié"
|
||||||
|
|
||||||
guardians = student.guardians.all()
|
guardians = student.guardians.all()
|
||||||
for guardian in guardians:
|
for guardian in guardians:
|
||||||
email = None
|
email = None
|
||||||
@ -373,13 +374,13 @@ class RegisterFormWithIdView(APIView):
|
|||||||
email = guardian.profile_role.profile.email
|
email = guardian.profile_role.profile.email
|
||||||
if not email:
|
if not email:
|
||||||
email = getattr(guardian, "email", None)
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
|
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
|
||||||
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
|
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
|
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
|
||||||
|
|
||||||
updateStateMachine(registerForm, 'EVENT_REFUSE')
|
updateStateMachine(registerForm, 'EVENT_REFUSE')
|
||||||
util.delete_registration_files(registerForm)
|
util.delete_registration_files(registerForm)
|
||||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
||||||
@ -411,6 +412,17 @@ class RegisterFormWithIdView(APIView):
|
|||||||
# Initialisation de la liste des fichiers à fusionner
|
# Initialisation de la liste des fichiers à fusionner
|
||||||
fileNames = []
|
fileNames = []
|
||||||
|
|
||||||
|
# Régénérer la fiche élève avec le nouveau template avant fusion
|
||||||
|
try:
|
||||||
|
base_dir = os.path.join(settings.MEDIA_ROOT, f"registration_files/dossier_rf_{registerForm.pk}")
|
||||||
|
os.makedirs(base_dir, exist_ok=True)
|
||||||
|
initial_pdf = f"{base_dir}/Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||||
|
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||||
|
registerForm.save()
|
||||||
|
logger.debug(f"[RF_VALIDATED] Fiche élève régénérée avant fusion")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_VALIDATED] Erreur lors de la régénération de la fiche élève: {e}")
|
||||||
|
|
||||||
# Ajout du fichier registration_file en première position
|
# Ajout du fichier registration_file en première position
|
||||||
if registerForm.registration_file:
|
if registerForm.registration_file:
|
||||||
fileNames.append(registerForm.registration_file.path)
|
fileNames.append(registerForm.registration_file.path)
|
||||||
@ -488,7 +500,7 @@ class RegisterFormWithIdView(APIView):
|
|||||||
class_name = None
|
class_name = None
|
||||||
if student.associated_class:
|
if student.associated_class:
|
||||||
class_name = student.associated_class.atmosphere_name
|
class_name = student.associated_class.atmosphere_name
|
||||||
|
|
||||||
guardians = student.guardians.all()
|
guardians = student.guardians.all()
|
||||||
for guardian in guardians:
|
for guardian in guardians:
|
||||||
email = None
|
email = None
|
||||||
@ -496,13 +508,13 @@ class RegisterFormWithIdView(APIView):
|
|||||||
email = guardian.profile_role.profile.email
|
email = guardian.profile_role.profile.email
|
||||||
if not email:
|
if not email:
|
||||||
email = getattr(guardian, "email", None)
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
|
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
|
||||||
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
|
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
|
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
|
||||||
|
|
||||||
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||||
email_thread = threading.Thread(target=send_validation_emails)
|
email_thread = threading.Thread(target=send_validation_emails)
|
||||||
email_thread.start()
|
email_thread.start()
|
||||||
@ -518,7 +530,7 @@ class RegisterFormWithIdView(APIView):
|
|||||||
student = registerForm.student
|
student = registerForm.student
|
||||||
student_name = f"{student.first_name} {student.last_name}"
|
student_name = f"{student.first_name} {student.last_name}"
|
||||||
notes = data.get('notes', '') or "Aucun motif spécifié"
|
notes = data.get('notes', '') or "Aucun motif spécifié"
|
||||||
|
|
||||||
guardians = student.guardians.all()
|
guardians = student.guardians.all()
|
||||||
for guardian in guardians:
|
for guardian in guardians:
|
||||||
email = None
|
email = None
|
||||||
@ -526,17 +538,17 @@ class RegisterFormWithIdView(APIView):
|
|||||||
email = guardian.profile_role.profile.email
|
email = guardian.profile_role.profile.email
|
||||||
if not email:
|
if not email:
|
||||||
email = getattr(guardian, "email", None)
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
if email:
|
if email:
|
||||||
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
|
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
|
||||||
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
|
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
|
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
|
||||||
|
|
||||||
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||||
email_thread = threading.Thread(target=send_refus_definitif_emails)
|
email_thread = threading.Thread(target=send_refus_definitif_emails)
|
||||||
email_thread.start()
|
email_thread.start()
|
||||||
|
|
||||||
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
|
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
|
||||||
|
|
||||||
# Retourner les données mises à jour
|
# Retourner les données mises à jour
|
||||||
@ -946,3 +958,26 @@ def get_parent_file_templates_by_rf(request, id):
|
|||||||
return JsonResponse(serializer.data, safe=False)
|
return JsonResponse(serializer.data, safe=False)
|
||||||
except RegistrationParentFileTemplate.DoesNotExist:
|
except RegistrationParentFileTemplate.DoesNotExist:
|
||||||
return JsonResponse({'error': 'Aucune pièce à fournir trouvée pour ce dossier d\'inscription'}, status=status.HTTP_404_NOT_FOUND)
|
return JsonResponse({'error': 'Aucune pièce à fournir trouvée pour ce dossier d\'inscription'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
method='get',
|
||||||
|
responses={200: openapi.Response('PDF file', schema=openapi.Schema(type=openapi.TYPE_FILE))},
|
||||||
|
operation_description="Génère et retourne le PDF de la fiche élève à la volée",
|
||||||
|
operation_summary="Télécharger la fiche élève (régénérée)"
|
||||||
|
)
|
||||||
|
@api_view(['GET'])
|
||||||
|
def generate_registration_pdf(request, id):
|
||||||
|
try:
|
||||||
|
registerForm = RegistrationForm.objects.select_related('student', 'establishment').get(student__id=id)
|
||||||
|
except RegistrationForm.DoesNotExist:
|
||||||
|
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_content = util.generateRegistrationPDF(registerForm)
|
||||||
|
except ValueError as e:
|
||||||
|
return JsonResponse({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
filename = f"Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||||
|
response = HttpResponse(pdf_content, content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'inline; filename="{filename}"'
|
||||||
|
return response
|
||||||
|
|||||||
@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Récupère un template d'inscription spécifique",
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
responses={
|
responses={
|
||||||
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def put(self, request, id):
|
def put(self, request, id):
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp is not None:
|
||||||
|
return resp
|
||||||
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
if template is None:
|
if template is None:
|
||||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
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)
|
serializer = RegistrationParentFileTemplateSerializer(template, data=payload, partial=True)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
|||||||
if resp:
|
if resp:
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# Garde-fou: eviter d'ecraser un master dynamique existant avec un
|
||||||
|
# formMasterData vide/malforme (cas observe en multipart).
|
||||||
|
if 'formMasterData' in payload:
|
||||||
|
incoming_form_data = payload.get('formMasterData')
|
||||||
|
current_is_dynamic = (
|
||||||
|
isinstance(master.formMasterData, dict)
|
||||||
|
and bool(master.formMasterData.get('fields'))
|
||||||
|
)
|
||||||
|
incoming_is_dynamic = (
|
||||||
|
isinstance(incoming_form_data, dict)
|
||||||
|
and bool(incoming_form_data.get('fields'))
|
||||||
|
)
|
||||||
|
if current_is_dynamic and not incoming_is_dynamic:
|
||||||
|
logger.warning(
|
||||||
|
"formMasterData invalide recu pour master %s: conservation de la config dynamique existante",
|
||||||
|
master.pk,
|
||||||
|
)
|
||||||
|
payload['formMasterData'] = master.formMasterData
|
||||||
|
|
||||||
|
|
||||||
logger.info(f"payload for update serializer: {payload}")
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
|||||||
@ -16,6 +16,20 @@ import Subscriptions.util as util
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_nested_responses(data, max_depth=8):
|
||||||
|
"""Extrait le dictionnaire de reponses depuis des structures imbriquees."""
|
||||||
|
current = data
|
||||||
|
for _ in range(max_depth):
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
nested = current.get("responses")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
current = nested
|
||||||
|
continue
|
||||||
|
return current
|
||||||
|
return current if isinstance(current, dict) else None
|
||||||
|
|
||||||
class RegistrationSchoolFileTemplateView(APIView):
|
class RegistrationSchoolFileTemplateView(APIView):
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||||
@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
responses = None
|
responses = None
|
||||||
if "responses" in formTemplateData:
|
if "responses" in formTemplateData:
|
||||||
resp = formTemplateData["responses"]
|
resp = formTemplateData["responses"]
|
||||||
if isinstance(resp, dict) and "responses" in resp:
|
responses = _extract_nested_responses(resp)
|
||||||
responses = resp["responses"]
|
|
||||||
elif isinstance(resp, dict):
|
# Nettoyer les meta-cles qui ne sont pas des reponses de champs
|
||||||
responses = resp
|
if isinstance(responses, dict):
|
||||||
|
cleaned = {
|
||||||
|
key: value
|
||||||
|
for key, value in responses.items()
|
||||||
|
if key not in {"responses", "formId", "id", "templateId"}
|
||||||
|
}
|
||||||
|
responses = cleaned
|
||||||
|
|
||||||
if responses and "fields" in formTemplateData:
|
if responses and "fields" in formTemplateData:
|
||||||
for field in formTemplateData["fields"]:
|
for field in formTemplateData["fields"]:
|
||||||
field_id = field.get("id")
|
field_id = field.get("id")
|
||||||
if field_id and field_id in responses:
|
if field_id and field_id in responses:
|
||||||
field["value"] = responses[field_id]
|
field["value"] = responses[field_id]
|
||||||
|
|
||||||
|
# Stocker les reponses aplaties pour eviter l'empilement responses.responses
|
||||||
|
if isinstance(responses, dict):
|
||||||
|
formTemplateData["responses"] = responses
|
||||||
payload['formTemplateData'] = formTemplateData
|
payload['formTemplateData'] = formTemplateData
|
||||||
|
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
@ -137,7 +162,7 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
# Cas 2 : Formulaire dynamique (JSON)
|
# Cas 2 : Formulaire dynamique (JSON)
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
# Régénérer le PDF si besoin
|
# Régénérer le PDF si besoin
|
||||||
@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
and formTemplateData.get("fields")
|
and formTemplateData.get("fields")
|
||||||
and hasattr(template, "file")
|
and hasattr(template, "file")
|
||||||
):
|
):
|
||||||
old_pdf_name = None
|
# Lire le contenu du fichier source en mémoire AVANT suppression.
|
||||||
if template.file and template.file.name:
|
# Priorité au fichier master (document source admin) pour éviter
|
||||||
old_pdf_name = os.path.basename(template.file.name)
|
# de re-générer à partir d'un PDF template déjà enrichi.
|
||||||
|
base_pdf_content = None
|
||||||
|
base_file_ext = None
|
||||||
|
if template.master and template.master.file and template.master.file.name:
|
||||||
|
base_file_ext = os.path.splitext(template.master.file.name)[1].lower()
|
||||||
try:
|
try:
|
||||||
|
template.master.file.open('rb')
|
||||||
|
base_pdf_content = template.master.file.read()
|
||||||
|
template.master.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source master: {e}")
|
||||||
|
elif template.file and template.file.name:
|
||||||
|
base_file_ext = os.path.splitext(template.file.name)[1].lower()
|
||||||
|
try:
|
||||||
|
template.file.open('rb')
|
||||||
|
base_pdf_content = template.file.read()
|
||||||
|
template.file.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture fichier source template: {e}")
|
||||||
|
try:
|
||||||
|
old_path = template.file.path
|
||||||
template.file.delete(save=False)
|
template.file.delete(save=False)
|
||||||
if os.path.exists(template.file.path):
|
if os.path.exists(old_path):
|
||||||
os.remove(template.file.path)
|
os.remove(old_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||||
from Subscriptions.util import generate_form_json_pdf
|
from Subscriptions.util import generate_form_json_pdf
|
||||||
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
pdf_file = generate_form_json_pdf(
|
||||||
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
template.registration_form,
|
||||||
template.file.save(pdf_filename, pdf_file, save=True)
|
formTemplateData,
|
||||||
|
base_pdf_content=base_pdf_content,
|
||||||
|
base_file_ext=base_file_ext,
|
||||||
|
)
|
||||||
|
form_name = (formTemplateData.get("title") or template.name or f"formulaire_{template.id}").strip().replace(" ", "_")
|
||||||
|
pdf_filename = f"{form_name}.pdf"
|
||||||
|
util.save_file_field_without_suffix(
|
||||||
|
template,
|
||||||
|
'file',
|
||||||
|
pdf_filename,
|
||||||
|
pdf_file,
|
||||||
|
save=True,
|
||||||
|
)
|
||||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
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)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,10 @@ def run_command(command):
|
|||||||
print(f"stderr: {stderr.decode()}")
|
print(f"stderr: {stderr.decode()}")
|
||||||
return process.returncode
|
return process.returncode
|
||||||
|
|
||||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
test_mode = os.getenv('TEST_MODE', 'false').lower() == 'true'
|
||||||
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
|
flush_data = os.getenv('FLUSH_DATA', 'false').lower() == 'true'
|
||||||
#flush_data=True
|
migrate_data = os.getenv('MIGRATE_DATA', 'false').lower() == 'true'
|
||||||
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
|
||||||
migrate_data=True
|
|
||||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||||
watch_mode=True
|
|
||||||
|
|
||||||
collect_static_cmd = [
|
collect_static_cmd = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"]
|
["python", "manage.py", "collectstatic", "--noinput"]
|
||||||
@ -64,12 +61,6 @@ if __name__ == "__main__":
|
|||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if flush_data:
|
|
||||||
for command in flush_data_cmd:
|
|
||||||
if run_command(command) != 0:
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
for command in migrate_commands:
|
for command in migrate_commands:
|
||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
@ -78,6 +69,11 @@ if __name__ == "__main__":
|
|||||||
if run_command(command) != 0:
|
if run_command(command) != 0:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
if flush_data:
|
||||||
|
for command in flush_data_cmd:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
if test_mode:
|
if test_mode:
|
||||||
for test_command in test_commands:
|
for test_command in test_commands:
|
||||||
if run_command(test_command) != 0:
|
if run_command(test_command) != 0:
|
||||||
|
|||||||
21
Front-End/messages/en/feedback.json
Normal file
21
Front-End/messages/en/feedback.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"title": "Feedback",
|
||||||
|
"description": "Share your feedback, report a bug, or suggest an improvement. We read every message!",
|
||||||
|
"category_label": "Category",
|
||||||
|
"category_placeholder": "Select a category",
|
||||||
|
"category_bug": "Report a bug",
|
||||||
|
"category_feature": "Suggest a feature",
|
||||||
|
"category_question": "Ask a question",
|
||||||
|
"category_other": "Other",
|
||||||
|
"subject_label": "Subject",
|
||||||
|
"subject_placeholder": "Summarize your request",
|
||||||
|
"message_label": "Message",
|
||||||
|
"message_placeholder": "Describe your feedback in detail...",
|
||||||
|
"send": "Send",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"success": "Success",
|
||||||
|
"success_message": "Your feedback has been sent. Thank you!",
|
||||||
|
"error": "Error",
|
||||||
|
"error_required_fields": "Please fill in all required fields.",
|
||||||
|
"error_sending": "An error occurred while sending your feedback."
|
||||||
|
}
|
||||||
@ -7,5 +7,6 @@
|
|||||||
"educational_monitoring": "Educational Monitoring",
|
"educational_monitoring": "Educational Monitoring",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"schoolAdmin": "School Administration",
|
"schoolAdmin": "School Administration",
|
||||||
"messagerie": "Messenger"
|
"messagerie": "Messenger",
|
||||||
|
"feedback": "Feedback"
|
||||||
}
|
}
|
||||||
|
|||||||
21
Front-End/messages/fr/feedback.json
Normal file
21
Front-End/messages/fr/feedback.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"title": "Feedback",
|
||||||
|
"description": "Partagez vos retours, signalez un bug ou proposez une amélioration. Nous lisons chaque message !",
|
||||||
|
"category_label": "Catégorie",
|
||||||
|
"category_placeholder": "Sélectionnez une catégorie",
|
||||||
|
"category_bug": "Signaler un bug",
|
||||||
|
"category_feature": "Proposer une fonctionnalité",
|
||||||
|
"category_question": "Poser une question",
|
||||||
|
"category_other": "Autre",
|
||||||
|
"subject_label": "Sujet",
|
||||||
|
"subject_placeholder": "Résumez votre demande",
|
||||||
|
"message_label": "Message",
|
||||||
|
"message_placeholder": "Décrivez en détail votre retour...",
|
||||||
|
"send": "Envoyer",
|
||||||
|
"sending": "Envoi en cours...",
|
||||||
|
"success": "Succès",
|
||||||
|
"success_message": "Votre feedback a bien été envoyé. Merci !",
|
||||||
|
"error": "Erreur",
|
||||||
|
"error_required_fields": "Veuillez remplir tous les champs obligatoires.",
|
||||||
|
"error_sending": "Une erreur est survenue lors de l'envoi du feedback."
|
||||||
|
}
|
||||||
@ -7,5 +7,6 @@
|
|||||||
"educational_monitoring": "Suivi pédagogique",
|
"educational_monitoring": "Suivi pédagogique",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"schoolAdmin": "Administration Scolaire",
|
"schoolAdmin": "Administration Scolaire",
|
||||||
"messagerie": "Messagerie"
|
"messagerie": "Messagerie",
|
||||||
|
"feedback": "Feedback"
|
||||||
}
|
}
|
||||||
|
|||||||
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { sendFeedback } from '@/app/actions/emailAction';
|
||||||
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import WisiwigTextArea from '@/components/Form/WisiwigTextArea';
|
||||||
|
import InputText from '@/components/Form/InputText';
|
||||||
|
import Button from '@/components/Form/Button';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
|
export default function FeedbackPage() {
|
||||||
|
const t = useTranslations('feedback');
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const { selectedEstablishmentId, establishments, user } = useEstablishment();
|
||||||
|
|
||||||
|
// Récupérer les infos complètes de l'établissement sélectionné
|
||||||
|
const selectedEstablishment = establishments?.find(
|
||||||
|
(e) => e.id === selectedEstablishmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const categoryChoices = [
|
||||||
|
{ value: 'bug', label: t('category_bug') },
|
||||||
|
{ value: 'feature', label: t('category_feature') },
|
||||||
|
{ value: 'question', label: t('category_question') },
|
||||||
|
{ value: 'other', label: t('category_other') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!category || !subject || !message) {
|
||||||
|
showNotification(t('error_required_fields'), 'error', t('error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Construire le nom de l'utilisateur (fallback vers l'email si nom indisponible)
|
||||||
|
const userName = user
|
||||||
|
? user.first_name && user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user.username || user.email?.split('@')[0] || ''
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const feedbackData = {
|
||||||
|
category,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
establishment: selectedEstablishment
|
||||||
|
? {
|
||||||
|
id: selectedEstablishment.id,
|
||||||
|
name: selectedEstablishment.name,
|
||||||
|
total_capacity: selectedEstablishment.total_capacity,
|
||||||
|
evaluation_frequency: selectedEstablishment.evaluation_frequency,
|
||||||
|
}
|
||||||
|
: { id: selectedEstablishmentId },
|
||||||
|
user_email: user?.email || '',
|
||||||
|
user_name: userName,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendFeedback(feedbackData);
|
||||||
|
showNotification(t('success_message'), 'success', t('success'));
|
||||||
|
// Réinitialiser les champs après succès
|
||||||
|
setCategory('');
|
||||||
|
setSubject('');
|
||||||
|
setMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Erreur lors de l'envoi du feedback:", { error });
|
||||||
|
showNotification(t('error_sending'), 'error', t('error'));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-4">
|
||||||
|
<div className="max-w-3xl mx-auto w-full">
|
||||||
|
<h1 className="text-2xl font-headline font-bold text-gray-800 mb-2">
|
||||||
|
{t('title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">{t('description')}</p>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
{/* Catégorie */}
|
||||||
|
<SelectChoice
|
||||||
|
name="category"
|
||||||
|
label={t('category_label')}
|
||||||
|
selected={category}
|
||||||
|
callback={(e) => setCategory(e.target.value)}
|
||||||
|
choices={categoryChoices}
|
||||||
|
placeHolder={t('category_placeholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sujet */}
|
||||||
|
<InputText
|
||||||
|
name="subject"
|
||||||
|
label={t('subject_label')}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder={t('subject_placeholder')}
|
||||||
|
required
|
||||||
|
className="mb-4 mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<WisiwigTextArea
|
||||||
|
label={t('message_label')}
|
||||||
|
value={message}
|
||||||
|
onChange={setMessage}
|
||||||
|
placeholder={t('message_placeholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton d'envoi */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
text={isSubmitting ? t('sending') : t('send')}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
MessageCircleHeart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
@ -24,6 +25,7 @@ import {
|
|||||||
FE_ADMIN_PLANNING_URL,
|
FE_ADMIN_PLANNING_URL,
|
||||||
FE_ADMIN_SETTINGS_URL,
|
FE_ADMIN_SETTINGS_URL,
|
||||||
FE_ADMIN_MESSAGERIE_URL,
|
FE_ADMIN_MESSAGERIE_URL,
|
||||||
|
FE_ADMIN_FEEDBACK_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
|
||||||
import { disconnect } from '@/app/actions/authAction';
|
import { disconnect } from '@/app/actions/authAction';
|
||||||
@ -82,6 +84,12 @@ export default function Layout({ children }) {
|
|||||||
url: FE_ADMIN_MESSAGERIE_URL,
|
url: FE_ADMIN_MESSAGERIE_URL,
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
|
feedback: {
|
||||||
|
id: 'feedback',
|
||||||
|
name: t('feedback'),
|
||||||
|
url: FE_ADMIN_FEEDBACK_URL,
|
||||||
|
icon: MessageCircleHeart,
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
name: t('settings'),
|
name: t('settings'),
|
||||||
|
|||||||
@ -1,17 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import SidebarTabs from '@/components/SidebarTabs';
|
import SidebarTabs from '@/components/SidebarTabs';
|
||||||
import EmailSender from '@/components/Admin/EmailSender';
|
|
||||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||||
import logger from '@/utils/logger';
|
|
||||||
|
|
||||||
export default function MessageriePage({ csrfToken }) {
|
export default function MessageriePage({ csrfToken }) {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
label: 'Envoyer un Mail',
|
|
||||||
content: <EmailSender csrfToken={csrfToken} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'instant',
|
id: 'instant',
|
||||||
label: 'Messagerie Instantanée',
|
label: 'Messagerie Instantanée',
|
||||||
|
|||||||
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||||
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
import {
|
||||||
|
fetchRegistrationFileGroups,
|
||||||
|
fetchRegistrationSchoolFileMasterById,
|
||||||
|
createRegistrationSchoolFileMaster,
|
||||||
|
editRegistrationSchoolFileMaster,
|
||||||
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import { FE_ADMIN_STRUCTURE_URL } from '@/utils/Url';
|
||||||
|
|
||||||
|
export default function FormBuilderPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
const formId = searchParams.get('id');
|
||||||
|
const preGroupId = searchParams.get('groupId');
|
||||||
|
const isEditing = !!formId;
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [initialData, setInitialData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploadedFile, setUploadedFile] = useState(null);
|
||||||
|
const [existingFileUrl, setExistingFileUrl] = useState(null);
|
||||||
|
|
||||||
|
const normalizeBackendFile = (rawFile, rawFileUrl) => {
|
||||||
|
if (typeof rawFileUrl === 'string' && rawFileUrl.trim()) {
|
||||||
|
return rawFileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawFile === 'string' && rawFile.trim()) {
|
||||||
|
return rawFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFile && typeof rawFile === 'object') {
|
||||||
|
if (typeof rawFile.url === 'string' && rawFile.url.trim()) {
|
||||||
|
return rawFile.url;
|
||||||
|
}
|
||||||
|
if (typeof rawFile.path === 'string' && rawFile.path.trim()) {
|
||||||
|
return rawFile.path;
|
||||||
|
}
|
||||||
|
if (typeof rawFile.name === 'string' && rawFile.name.trim()) {
|
||||||
|
return rawFile.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewFileUrl = useMemo(() => {
|
||||||
|
if (uploadedFile instanceof File) {
|
||||||
|
return URL.createObjectURL(uploadedFile);
|
||||||
|
}
|
||||||
|
return existingFileUrl || null;
|
||||||
|
}, [uploadedFile, existingFileUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewFileUrl && previewFileUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewFileUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewFileUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedEstablishmentId) return;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetchRegistrationFileGroups(selectedEstablishmentId),
|
||||||
|
formId ? fetchRegistrationSchoolFileMasterById(formId) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
.then(([groupsData, formData]) => {
|
||||||
|
setGroups(groupsData || []);
|
||||||
|
if (formData) {
|
||||||
|
setInitialData(formData);
|
||||||
|
const resolvedFile = normalizeBackendFile(
|
||||||
|
formData.file,
|
||||||
|
formData.file_url
|
||||||
|
);
|
||||||
|
if (resolvedFile) {
|
||||||
|
setExistingFileUrl(resolvedFile);
|
||||||
|
}
|
||||||
|
} else if (preGroupId) {
|
||||||
|
setInitialData({ groups: [{ id: Number(preGroupId) }] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('Error loading FormBuilder data:', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [selectedEstablishmentId, formId, preGroupId]);
|
||||||
|
|
||||||
|
const buildFormData = async (name, group_ids, formMasterData) => {
|
||||||
|
const dataToSend = new FormData();
|
||||||
|
dataToSend.append(
|
||||||
|
'data',
|
||||||
|
JSON.stringify({
|
||||||
|
name,
|
||||||
|
groups: group_ids,
|
||||||
|
formMasterData,
|
||||||
|
establishment: selectedEstablishmentId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadedFile instanceof File) {
|
||||||
|
const ext =
|
||||||
|
uploadedFile.name.lastIndexOf('.') !== -1
|
||||||
|
? uploadedFile.name.substring(uploadedFile.name.lastIndexOf('.'))
|
||||||
|
: '';
|
||||||
|
const cleanName = (name || 'document')
|
||||||
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
dataToSend.append('file', uploadedFile, `${cleanName}${ext}`);
|
||||||
|
} else if (existingFileUrl && isEditing) {
|
||||||
|
const lastDot = existingFileUrl.lastIndexOf('.');
|
||||||
|
const ext = lastDot !== -1 ? existingFileUrl.substring(lastDot) : '';
|
||||||
|
const cleanName = (name || 'document')
|
||||||
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(getSecureFileUrl(existingFileUrl));
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
dataToSend.append('file', blob, `${cleanName}${ext}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Could not re-fetch existing file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataToSend;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async ({ name, group_ids, formMasterData, id }) => {
|
||||||
|
const hasFileField = (formMasterData?.fields || []).some(
|
||||||
|
(field) => field.type === 'file'
|
||||||
|
);
|
||||||
|
const hasUploadedDocument =
|
||||||
|
uploadedFile instanceof File || Boolean(existingFileUrl);
|
||||||
|
|
||||||
|
if (hasFileField && !hasUploadedDocument) {
|
||||||
|
showNotification(
|
||||||
|
'Un document PDF doit être uploadé si le formulaire contient un champ fichier.',
|
||||||
|
'error',
|
||||||
|
'Erreur'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToSend = await buildFormData(name, group_ids, formMasterData);
|
||||||
|
if (isEditing) {
|
||||||
|
await editRegistrationSchoolFileMaster(id || formId, dataToSend, csrfToken);
|
||||||
|
showNotification(
|
||||||
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await createRegistrationSchoolFileMaster(dataToSend, csrfToken);
|
||||||
|
showNotification(
|
||||||
|
`Le formulaire "${name}" a été créé avec succès.`,
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
router.push(FE_ADMIN_STRUCTURE_URL);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error saving form:', err);
|
||||||
|
showNotification('Erreur lors de la sauvegarde du formulaire', 'error', 'Erreur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<p className="text-gray-500">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-neutral">
|
||||||
|
{/* Header sticky */}
|
||||||
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(FE_ADMIN_STRUCTURE_URL)}
|
||||||
|
className="flex items-center gap-2 text-primary hover:text-secondary font-label font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-headline font-semibold text-gray-800">
|
||||||
|
{isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||||
|
{/* FormTemplateBuilder */}
|
||||||
|
<FormTemplateBuilder
|
||||||
|
onSave={handleSave}
|
||||||
|
initialData={initialData}
|
||||||
|
groups={groups}
|
||||||
|
isEditing={isEditing}
|
||||||
|
masterFile={previewFileUrl}
|
||||||
|
onMasterFileUpload={(file) => setUploadedFile(file)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -205,10 +205,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredStudents, fetchedAbsences]);
|
}, [filteredStudents, fetchedAbsences]);
|
||||||
|
|
||||||
// Load specialities for evaluations
|
// Load specialities for evaluations (filtered by current school year)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEstablishmentId) {
|
if (selectedEstablishmentId) {
|
||||||
fetchSpecialities(selectedEstablishmentId)
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
|
const currentSchoolYear = `${year}-${year + 1}`;
|
||||||
|
fetchSpecialities(selectedEstablishmentId, currentSchoolYear)
|
||||||
.then((data) => setSpecialities(data))
|
.then((data) => setSpecialities(data))
|
||||||
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
|
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Table from '@/components/Table';
|
|
||||||
import {
|
import {
|
||||||
Edit3,
|
Edit3,
|
||||||
Users,
|
Users,
|
||||||
@ -9,6 +8,12 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Upload,
|
Upload,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Award,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
BookOpen,
|
||||||
|
ArrowLeft,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import StatusLabel from '@/components/StatusLabel';
|
import StatusLabel from '@/components/StatusLabel';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
@ -16,7 +21,13 @@ import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
|
|||||||
import {
|
import {
|
||||||
fetchChildren,
|
fetchChildren,
|
||||||
editRegisterForm,
|
editRegisterForm,
|
||||||
|
fetchStudentCompetencies,
|
||||||
|
fetchAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import {
|
||||||
|
fetchEvaluations,
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
@ -27,13 +38,47 @@ import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
|||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import ParentPlanningSection from '@/components/ParentPlanningSection';
|
import ParentPlanningSection from '@/components/ParentPlanningSection';
|
||||||
import EventCard from '@/components/EventCard';
|
import EventCard from '@/components/EventCard';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// Fonction utilitaire pour générer la chaîne de période
|
||||||
|
function getPeriodString(selectedPeriod, frequency) {
|
||||||
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
|
const nextYear = (year + 1).toString();
|
||||||
|
const schoolYear = `${year}-${nextYear}`;
|
||||||
|
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 3) return `A_${schoolYear}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour obtenir les périodes selon la fréquence d'évaluation
|
||||||
|
function getPeriods(frequency) {
|
||||||
|
if (frequency === 1) {
|
||||||
|
return [
|
||||||
|
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||||
|
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||||
|
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (frequency === 2) {
|
||||||
|
return [
|
||||||
|
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||||
|
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (frequency === 3) {
|
||||||
|
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export default function ParentHomePage() {
|
export default function ParentHomePage() {
|
||||||
const [children, setChildren] = useState([]);
|
const [children, setChildren] = useState([]);
|
||||||
const { user, selectedEstablishmentId } = useEstablishment();
|
const { user, selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
|
||||||
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload
|
const [uploadingStudentId, setUploadingStudentId] = useState(null);
|
||||||
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé
|
const [uploadedFile, setUploadedFile] = useState(null);
|
||||||
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant
|
const [uploadState, setUploadState] = useState('off');
|
||||||
const [showPlanning, setShowPlanning] = useState(false);
|
const [showPlanning, setShowPlanning] = useState(false);
|
||||||
const [planningClassName, setPlanningClassName] = useState(null);
|
const [planningClassName, setPlanningClassName] = useState(null);
|
||||||
const [upcomingEvents, setUpcomingEvents] = useState([]);
|
const [upcomingEvents, setUpcomingEvents] = useState([]);
|
||||||
@ -42,16 +87,114 @@ export default function ParentHomePage() {
|
|||||||
const [reloadFetch, setReloadFetch] = useState(false);
|
const [reloadFetch, setReloadFetch] = useState(false);
|
||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
|
|
||||||
|
// États pour la vue détaillée de l'élève inscrit
|
||||||
|
const [expandedStudentId, setExpandedStudentId] = useState(null);
|
||||||
|
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||||
|
const [grades, setGrades] = useState({});
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
|
const [evaluations, setEvaluations] = useState([]);
|
||||||
|
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||||
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// Périodes disponibles selon la fréquence d'évaluation
|
||||||
|
const periods = useMemo(
|
||||||
|
() => getPeriods(selectedEstablishmentEvaluationFrequency),
|
||||||
|
[selectedEstablishmentEvaluationFrequency]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-sélection de la période courante
|
||||||
|
useEffect(() => {
|
||||||
|
if (periods.length > 0 && !selectedPeriod) {
|
||||||
|
const today = dayjs();
|
||||||
|
const current = periods.find((p) => {
|
||||||
|
const start = dayjs(`${today.year()}-${p.start}`);
|
||||||
|
const end = dayjs(`${today.year()}-${p.end}`);
|
||||||
|
return today.isAfter(start.subtract(1, 'day')) && today.isBefore(end.add(1, 'day'));
|
||||||
|
});
|
||||||
|
setSelectedPeriod(current ? current.value : periods[0]?.value);
|
||||||
|
}
|
||||||
|
}, [periods, selectedPeriod]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user !== null) {
|
if (user !== null) {
|
||||||
const userIdFromSession = user.user_id;
|
const userIdFromSession = user.user_id;
|
||||||
fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => {
|
fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => {
|
||||||
setChildren(data);
|
setChildren(data);
|
||||||
|
// Auto-expand si un seul enfant inscrit
|
||||||
|
const enrolledChildren = (data || []).filter((c) => c.status === 5);
|
||||||
|
if (enrolledChildren.length === 1) {
|
||||||
|
setExpandedStudentId(enrolledChildren[0].student.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setReloadFetch(false);
|
setReloadFetch(false);
|
||||||
}
|
}
|
||||||
}, [selectedEstablishmentId, reloadFetch, user]);
|
}, [selectedEstablishmentId, reloadFetch, user]);
|
||||||
|
|
||||||
|
// Charger les absences
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
fetchAbsences(selectedEstablishmentId)
|
||||||
|
.then((data) => setAllAbsences(data || []))
|
||||||
|
.catch((error) => logger.error('Erreur fetch absences:', error));
|
||||||
|
}
|
||||||
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
|
// Charger les données détaillées quand un élève est étendu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandedStudentId || !selectedPeriod || !selectedEstablishmentEvaluationFrequency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedChild = children.find((c) => c.student.id === expandedStudentId);
|
||||||
|
if (!expandedChild || expandedChild.status !== 5) return;
|
||||||
|
|
||||||
|
const loadDetails = async () => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Charger les compétences
|
||||||
|
const competenciesData = await fetchStudentCompetencies(expandedStudentId, periodString);
|
||||||
|
setStudentCompetencies(competenciesData);
|
||||||
|
if (competenciesData?.data) {
|
||||||
|
const initialGrades = {};
|
||||||
|
competenciesData.data.forEach((domaine) => {
|
||||||
|
domaine.categories.forEach((cat) => {
|
||||||
|
cat.competences.forEach((comp) => {
|
||||||
|
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setGrades(initialGrades);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger les évaluations si l'élève a une classe
|
||||||
|
if (expandedChild.student.associated_class_id) {
|
||||||
|
const [evalData, studentEvalData] = await Promise.all([
|
||||||
|
fetchEvaluations(
|
||||||
|
selectedEstablishmentId,
|
||||||
|
expandedChild.student.associated_class_id,
|
||||||
|
periodString
|
||||||
|
),
|
||||||
|
fetchStudentEvaluations(expandedStudentId, null, periodString, null)
|
||||||
|
]);
|
||||||
|
setEvaluations(evalData || []);
|
||||||
|
setStudentEvaluationsData(studentEvalData || []);
|
||||||
|
} else {
|
||||||
|
setEvaluations([]);
|
||||||
|
setStudentEvaluationsData([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors du chargement des détails:', error);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDetails();
|
||||||
|
}, [expandedStudentId, selectedPeriod, selectedEstablishmentEvaluationFrequency, children, selectedEstablishmentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEstablishmentId) {
|
if (selectedEstablishmentId) {
|
||||||
// Fetch des événements à venir
|
// Fetch des événements à venir
|
||||||
@ -132,153 +275,6 @@ export default function ParentHomePage() {
|
|||||||
setShowPlanning(true);
|
setShowPlanning(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const childrenColumns = [
|
|
||||||
{
|
|
||||||
name: 'photo',
|
|
||||||
transform: (row) => (
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
{row.student.photo ? (
|
|
||||||
<a
|
|
||||||
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={getSecureFileUrl(row.student.photo)}
|
|
||||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
|
||||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
|
||||||
<span className="text-gray-500 text-sm font-semibold">
|
|
||||||
{row.student.first_name[0]}
|
|
||||||
{row.student.last_name[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ name: 'Nom', transform: (row) => `${row.student.last_name}` },
|
|
||||||
{ name: 'Prénom', transform: (row) => `${row.student.first_name}` },
|
|
||||||
{
|
|
||||||
name: 'Classe',
|
|
||||||
transform: (row) => (
|
|
||||||
<div className="text-center">{row.student.associated_class_name}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Niveau',
|
|
||||||
transform: (row) => (
|
|
||||||
<div className="text-center">{getNiveauLabel(row.student.level)}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Statut',
|
|
||||||
transform: (row) => (
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<StatusLabel status={row.status} showDropdown={false} parent />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Actions',
|
|
||||||
transform: (row) => (
|
|
||||||
<div className="flex justify-center items-center gap-2">
|
|
||||||
{row.status === 2 && (
|
|
||||||
<button
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(row.student.id);
|
|
||||||
}}
|
|
||||||
aria-label="Remplir le dossier"
|
|
||||||
>
|
|
||||||
<Edit3 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(row.status === 3 || row.status === 8) && (
|
|
||||||
<button
|
|
||||||
className="text-purple-500 hover:text-purple-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleView(row.student.id);
|
|
||||||
}}
|
|
||||||
aria-label="Visualiser le dossier"
|
|
||||||
>
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.status === 7 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full text-purple-500 hover:text-purple-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleView(row.student.id);
|
|
||||||
}}
|
|
||||||
aria-label="Visualiser le dossier"
|
|
||||||
>
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={getSecureFileUrl(row.sepa_file)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
|
|
||||||
aria-label="Télécharger le mandat SEPA"
|
|
||||||
>
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
|
||||||
uploadingStudentId === row.student.id && uploadState === 'on'
|
|
||||||
? 'bg-blue-100 text-blue-600 ring-3 ring-blue-500'
|
|
||||||
: 'text-blue-500 hover:text-blue-700'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleUpload(row.student.id);
|
|
||||||
}}
|
|
||||||
aria-label="Uploader un fichier"
|
|
||||||
>
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{row.status === 5 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="text-purple-500 hover:text-purple-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleView(row.student.id);
|
|
||||||
}}
|
|
||||||
aria-label="Visualiser le dossier"
|
|
||||||
>
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="text-emerald-500 hover:text-emerald-700 ml-1"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
showClassPlanning(row.student);
|
|
||||||
}}
|
|
||||||
aria-label="Voir le planning de la classe"
|
|
||||||
>
|
|
||||||
<CalendarDays className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
{showPlanning && planningClassName ? (
|
{showPlanning && planningClassName ? (
|
||||||
@ -326,29 +322,375 @@ export default function ParentHomePage() {
|
|||||||
title="Vos enfants"
|
title="Vos enfants"
|
||||||
description="Suivez le parcours de vos enfants"
|
description="Suivez le parcours de vos enfants"
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table data={children} columns={childrenColumns} />
|
{/* Cartes des enfants */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{children.map((child) => {
|
||||||
|
const student = child.student;
|
||||||
|
const isEnrolled = child.status === 5;
|
||||||
|
const isExpanded = expandedStudentId === student.id;
|
||||||
|
|
||||||
|
// Absences pour cet élève (détaillées par type)
|
||||||
|
const studentAbsencesList = allAbsences.filter((a) => a.student === student.id);
|
||||||
|
const absenceStats = {
|
||||||
|
justifiedAbsence: studentAbsencesList.filter((a) => a.reason === 1).length,
|
||||||
|
unjustifiedAbsence: studentAbsencesList.filter((a) => a.reason === 2).length,
|
||||||
|
justifiedLate: studentAbsencesList.filter((a) => a.reason === 3).length,
|
||||||
|
unjustifiedLate: studentAbsencesList.filter((a) => a.reason === 4).length,
|
||||||
|
};
|
||||||
|
const totalAbsences = absenceStats.justifiedAbsence + absenceStats.unjustifiedAbsence;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={student.id}
|
||||||
|
className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* En-tête de la carte (toujours visible) */}
|
||||||
|
<div
|
||||||
|
className={`p-4 flex flex-col sm:flex-row items-start sm:items-center gap-4 ${
|
||||||
|
isEnrolled ? 'cursor-pointer hover:bg-gray-50' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEnrolled) {
|
||||||
|
setExpandedStudentId(isExpanded ? null : student.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{student.photo ? (
|
||||||
|
<img
|
||||||
|
src={getSecureFileUrl(student.photo)}
|
||||||
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
|
className="w-16 h-16 object-cover rounded-full border-2 border-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-xl">
|
||||||
|
{student.first_name?.[0]}{student.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infos principales */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
|
{student.last_name} {student.first_name}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StatusLabel status={child.status} showDropdown={false} parent />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-2">
|
||||||
|
{student.associated_class_name && (
|
||||||
|
<span>Classe : <span className="font-medium">{student.associated_class_name}</span></span>
|
||||||
|
)}
|
||||||
|
{student.level !== undefined && (
|
||||||
|
<span className="ml-3">Niveau : <span className="font-medium">{getNiveauLabel(student.level)}</span></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isEnrolled && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||||
|
<Award className="w-3 h-3" />
|
||||||
|
<span>Cliquez pour voir le suivi pédagogique</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{child.status === 2 && (
|
||||||
|
<button
|
||||||
|
className="p-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(student.id);
|
||||||
|
}}
|
||||||
|
title="Remplir le dossier"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(child.status === 3 || child.status === 8 || child.status === 5 || child.status === 7) && (
|
||||||
|
<button
|
||||||
|
className="p-2 text-purple-500 hover:text-purple-700 hover:bg-purple-50 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleView(student.id);
|
||||||
|
}}
|
||||||
|
title="Visualiser le dossier"
|
||||||
|
>
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{child.status === 7 && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={getSecureFileUrl(child.sepa_file)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 text-green-500 hover:text-green-700 hover:bg-green-50 rounded-full"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Télécharger le mandat SEPA"
|
||||||
|
>
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
uploadingStudentId === student.id && uploadState === 'on'
|
||||||
|
? 'bg-blue-100 text-blue-600'
|
||||||
|
: 'text-blue-500 hover:text-blue-700 hover:bg-blue-50'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleUpload(student.id);
|
||||||
|
}}
|
||||||
|
title="Uploader un fichier"
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEnrolled && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="p-2 text-primary hover:text-secondary hover:bg-tertiary/10 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showClassPlanning(student);
|
||||||
|
}}
|
||||||
|
title="Voir le planning de la classe"
|
||||||
|
>
|
||||||
|
<CalendarDays className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="ml-2">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload SEPA si activé */}
|
||||||
|
{uploadState === 'on' && uploadingStudentId === student.id && (
|
||||||
|
<div className="p-4 border-t bg-gray-50">
|
||||||
|
<FileUpload
|
||||||
|
selectionMessage="Sélectionnez un fichier à uploader"
|
||||||
|
onFileSelect={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`mt-4 px-6 py-2 rounded-md ${
|
||||||
|
uploadedFile
|
||||||
|
? 'bg-primary text-white hover:bg-secondary'
|
||||||
|
: 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!uploadedFile}
|
||||||
|
>
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section détaillée pour les élèves inscrits (expanded) */}
|
||||||
|
{isEnrolled && isExpanded && (
|
||||||
|
<div className="border-t bg-stone-50 p-4 space-y-6">
|
||||||
|
|
||||||
|
{/* Bloc période : compétences + notes */}
|
||||||
|
<div className="bg-white rounded-lg border border-primary/20 p-4 space-y-4">
|
||||||
|
{/* Sélecteur de période */}
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-100">
|
||||||
|
<div className="w-full sm:w-48">
|
||||||
|
<SelectChoice
|
||||||
|
name="period"
|
||||||
|
label="Période"
|
||||||
|
placeHolder="Choisir la période"
|
||||||
|
choices={periods.map((period) => ({
|
||||||
|
value: period.value,
|
||||||
|
label: period.label,
|
||||||
|
}))}
|
||||||
|
selected={selectedPeriod || ''}
|
||||||
|
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Chargement des données...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Résumé des compétences (pourcentages) */}
|
||||||
|
{(() => {
|
||||||
|
const total = Object.keys(grades).length;
|
||||||
|
const acquired = Object.values(grades).filter((g) => g === 3).length;
|
||||||
|
const inProgress = Object.values(grades).filter((g) => g === 2).length;
|
||||||
|
const notAcquired = Object.values(grades).filter((g) => g === 1).length;
|
||||||
|
const notEvaluated = Object.values(grades).filter((g) => g === 0).length;
|
||||||
|
|
||||||
|
const pctAcquired = total ? Math.round((acquired / total) * 100) : 0;
|
||||||
|
const pctInProgress = total ? Math.round((inProgress / total) * 100) : 0;
|
||||||
|
const pctNotAcquired = total ? Math.round((notAcquired / total) * 100) : 0;
|
||||||
|
const pctNotEvaluated = total ? Math.round((notEvaluated / total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-100 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Award className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
|
Compétences
|
||||||
|
</h3>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">({total} compétences)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{total > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="flex flex-col items-center p-3 bg-emerald-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-emerald-600">{pctAcquired}%</span>
|
||||||
|
<span className="text-sm text-emerald-700">Acquises</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-yellow-600">{pctInProgress}%</span>
|
||||||
|
<span className="text-sm text-yellow-700">En cours</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-red-500">{pctNotAcquired}%</span>
|
||||||
|
<span className="text-sm text-red-600">Non acquises</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-gray-100 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-gray-500">{pctNotEvaluated}%</span>
|
||||||
|
<span className="text-sm text-gray-600">Non évaluées</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">Aucune compétence évaluée pour cette période.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Notes par matière - Vue simplifiée */}
|
||||||
|
<div className="border border-gray-100 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Award className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
|
Notes par matière
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{evaluations.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{(() => {
|
||||||
|
// Grouper par matière
|
||||||
|
const bySpeciality = evaluations.reduce((acc, ev) => {
|
||||||
|
const key = ev.speciality_name || 'Sans matière';
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
name: key,
|
||||||
|
color: ev.speciality_color || '#6B7280',
|
||||||
|
evaluations: [],
|
||||||
|
totalWeighted: 0,
|
||||||
|
totalCoef: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const studentEval = studentEvaluationsData.find((se) => se.evaluation === ev.id);
|
||||||
|
acc[key].evaluations.push({ ...ev, studentScore: studentEval?.score, isAbsent: studentEval?.is_absent });
|
||||||
|
if (studentEval?.score != null && !studentEval?.is_absent) {
|
||||||
|
const normalized = (studentEval.score / ev.max_score) * 20;
|
||||||
|
acc[key].totalWeighted += normalized * ev.coefficient;
|
||||||
|
acc[key].totalCoef += parseFloat(ev.coefficient);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.values(bySpeciality).map((group) => {
|
||||||
|
const avg = group.totalCoef > 0 ? (group.totalWeighted / group.totalCoef) : null;
|
||||||
|
const evalCount = group.evaluations.length;
|
||||||
|
const gradedCount = group.evaluations.filter((e) => e.studentScore != null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.name}
|
||||||
|
className="rounded-lg p-4 border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${group.color}10`,
|
||||||
|
borderColor: `${group.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: group.color }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-gray-800 truncate">{group.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<span
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ color: group.color }}
|
||||||
|
>
|
||||||
|
{avg !== null ? avg.toFixed(1) : '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">/20</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{gradedCount}/{evalCount} évaluation{evalCount > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm text-center py-4">Aucune évaluation pour cette période.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Fin bloc période */}
|
||||||
|
|
||||||
|
{/* Section Absences — toute l'année scolaire */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Clock className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">
|
||||||
|
Absences & Retards
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mb-4">Toute l'année scolaire</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="flex flex-col items-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-green-600">{absenceStats.justifiedAbsence}</span>
|
||||||
|
<span className="text-sm text-green-700 text-center">Absences justifiées</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-red-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-red-500">{absenceStats.unjustifiedAbsence}</span>
|
||||||
|
<span className="text-sm text-red-600 text-center">Absences non justifiées</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-blue-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-blue-600">{absenceStats.justifiedLate}</span>
|
||||||
|
<span className="text-sm text-blue-700 text-center">Retards justifiés</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center p-3 bg-orange-50 rounded-lg">
|
||||||
|
<span className="text-2xl font-bold text-orange-500">{absenceStats.unjustifiedLate}</span>
|
||||||
|
<span className="text-sm text-orange-600 text-center">Retards non justifiés</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* Composant FileUpload et bouton Valider en dessous du tableau */}
|
|
||||||
{uploadState === 'on' && uploadingStudentId && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<FileUpload
|
|
||||||
selectionMessage="Sélectionnez un fichier à uploader"
|
|
||||||
onFileSelect={handleFileUpload}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className={`mt-4 px-6 py-2 rounded-md ${
|
|
||||||
uploadedFile
|
|
||||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
|
||||||
: 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!uploadedFile}
|
|
||||||
>
|
|
||||||
Valider
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
||||||
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
||||||
|
BE_GESTIONEMAIL_SEND_FEEDBACK_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||||
import { getCsrfToken } from '@/utils/getCsrfToken';
|
import { getCsrfToken } from '@/utils/getCsrfToken';
|
||||||
@ -19,3 +20,13 @@ export const sendEmail = async (messageData) => {
|
|||||||
body: JSON.stringify(messageData),
|
body: JSON.stringify(messageData),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Envoyer un feedback au support
|
||||||
|
export const sendFeedback = async (feedbackData) => {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
return fetchWithAuth(BE_GESTIONEMAIL_SEND_FEEDBACK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': csrfToken },
|
||||||
|
body: JSON.stringify(feedbackData),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => {
|
|||||||
return fetchWithAuth(url);
|
return fetchWithAuth(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchRegistrationSchoolFileMasterById = (id) => {
|
||||||
|
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||||
return fetchWithAuth(url);
|
return fetchWithAuth(url);
|
||||||
|
|||||||
@ -37,10 +37,10 @@ export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchSpecialities = (establishment) => {
|
export const fetchSpecialities = (establishment, schoolYear = null) => {
|
||||||
return fetchWithAuth(
|
let url = `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`;
|
||||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
if (schoolYear) url += `&school_year=${schoolYear}`;
|
||||||
);
|
return fetchWithAuth(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTeachers = (establishment) => {
|
export const fetchTeachers = (establishment) => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form';
|
|||||||
import InputTextIcon from './InputTextIcon';
|
import InputTextIcon from './InputTextIcon';
|
||||||
import SelectChoice from './SelectChoice';
|
import SelectChoice from './SelectChoice';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
|
import FileUpload from './FileUpload';
|
||||||
import IconSelector from './IconSelector';
|
import IconSelector from './IconSelector';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { FIELD_TYPES } from './FormTypes';
|
import { FIELD_TYPES } from './FormTypes';
|
||||||
@ -14,6 +15,8 @@ export default function AddFieldModal({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
editingField = null,
|
editingField = null,
|
||||||
editingIndex = -1,
|
editingIndex = -1,
|
||||||
|
hasMasterFile = false,
|
||||||
|
onMasterFileUpload,
|
||||||
}) {
|
}) {
|
||||||
const isEditing = editingIndex >= 0;
|
const isEditing = editingIndex >= 0;
|
||||||
|
|
||||||
@ -29,6 +32,7 @@ export default function AddFieldModal({
|
|||||||
acceptTypes: '',
|
acceptTypes: '',
|
||||||
maxSize: 5, // 5MB par défaut
|
maxSize: 5, // 5MB par défaut
|
||||||
checked: false,
|
checked: false,
|
||||||
|
masterFileToUpload: null,
|
||||||
validation: {
|
validation: {
|
||||||
pattern: '',
|
pattern: '',
|
||||||
minLength: '',
|
minLength: '',
|
||||||
@ -56,6 +60,7 @@ export default function AddFieldModal({
|
|||||||
acceptTypes: '',
|
acceptTypes: '',
|
||||||
maxSize: 5,
|
maxSize: 5,
|
||||||
checked: false,
|
checked: false,
|
||||||
|
masterFileToUpload: null,
|
||||||
signatureData: '',
|
signatureData: '',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
penColor: '#000000',
|
penColor: '#000000',
|
||||||
@ -492,6 +497,31 @@ export default function AddFieldModal({
|
|||||||
|
|
||||||
{currentField.type === 'file' && (
|
{currentField.type === 'file' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Document PDF du formulaire{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<FileUpload
|
||||||
|
selectionMessage="Uploader le PDF à afficher dans l'aperçu"
|
||||||
|
onFileSelect={(file) => {
|
||||||
|
setCurrentField((prev) => ({
|
||||||
|
...prev,
|
||||||
|
masterFileToUpload: file,
|
||||||
|
}));
|
||||||
|
if (onMasterFileUpload) {
|
||||||
|
onMasterFileUpload(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
enable
|
||||||
|
/>
|
||||||
|
{!hasMasterFile && !currentField.masterFileToUpload && (
|
||||||
|
<p className="text-xs text-red-500 mt-2">
|
||||||
|
Uploadez un document avant d'ajouter ce type de champ.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="acceptTypes"
|
name="acceptTypes"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import logger from '@/utils/logger';
|
|||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import SelectChoice from './SelectChoice';
|
import SelectChoice from './SelectChoice';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import InputTextIcon from './InputTextIcon';
|
import InputTextIcon from './InputTextIcon';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
@ -33,7 +34,22 @@ export default function FormRenderer({
|
|||||||
onFormSubmit = (data) => {
|
onFormSubmit = (data) => {
|
||||||
alert(JSON.stringify(data, null, 2));
|
alert(JSON.stringify(data, null, 2));
|
||||||
}, // Callback de soumission personnalisé (optionnel)
|
}, // Callback de soumission personnalisé (optionnel)
|
||||||
|
masterFile = null,
|
||||||
}) {
|
}) {
|
||||||
|
const resolveMasterFileUrl = (fileValue) => {
|
||||||
|
if (!fileValue) return null;
|
||||||
|
if (typeof fileValue !== 'string') return null;
|
||||||
|
if (fileValue.startsWith('blob:')) return fileValue;
|
||||||
|
if (fileValue.startsWith('data:')) return fileValue;
|
||||||
|
if (fileValue.startsWith('http://') || fileValue.startsWith('https://')) {
|
||||||
|
return fileValue;
|
||||||
|
}
|
||||||
|
if (fileValue.startsWith('/api/download?')) return fileValue;
|
||||||
|
return getSecureFileUrl(fileValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const masterFileUrl = resolveMasterFileUrl(masterFile);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
@ -57,8 +73,7 @@ export default function FormRenderer({
|
|||||||
const hasFiles = Object.keys(data).some((key) => {
|
const hasFiles = Object.keys(data).some((key) => {
|
||||||
return (
|
return (
|
||||||
data[key] instanceof FileList ||
|
data[key] instanceof FileList ||
|
||||||
(data[key] && data[key][0] instanceof File) ||
|
(data[key] && data[key][0] instanceof File)
|
||||||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,29 +98,6 @@ export default function FormRenderer({
|
|||||||
formData.append(`files.${key}`, value[i]);
|
formData.append(`files.${key}`, value[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
typeof value === 'string' &&
|
|
||||||
value.startsWith('data:image')
|
|
||||||
) {
|
|
||||||
// Gérer les signatures (SVG ou images base64)
|
|
||||||
if (value.includes('svg+xml')) {
|
|
||||||
// Gérer les signatures SVG
|
|
||||||
const svgData = value.split(',')[1];
|
|
||||||
const svgBlob = new Blob([atob(svgData)], {
|
|
||||||
type: 'image/svg+xml',
|
|
||||||
});
|
|
||||||
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
|
|
||||||
} else {
|
|
||||||
// Gérer les images base64 classiques
|
|
||||||
const byteString = atob(value.split(',')[1]);
|
|
||||||
const ab = new ArrayBuffer(byteString.length);
|
|
||||||
const ia = new Uint8Array(ab);
|
|
||||||
for (let i = 0; i < byteString.length; i++) {
|
|
||||||
ia[i] = byteString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
const blob = new Blob([ab], { type: 'image/png' });
|
|
||||||
formData.append(`files.${key}`, blob, `signature_${key}.png`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Gérer les autres types de champs
|
// Gérer les autres types de champs
|
||||||
formData.append(
|
formData.append(
|
||||||
@ -356,24 +348,39 @@ export default function FormRenderer({
|
|||||||
control={control}
|
control={control}
|
||||||
rules={{ required: field.required }}
|
rules={{ required: field.required }}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<FileUpload
|
masterFileUrl ? (
|
||||||
selectionMessage={field.label}
|
<div className="w-full bg-neutral border border-gray-200 rounded p-3">
|
||||||
required={field.required}
|
{field.label && (
|
||||||
uploadedFileName={value ? value[0]?.name : null}
|
<p className="text-sm font-medium text-gray-700 mb-2">
|
||||||
onFileSelect={(file) => {
|
{field.label}
|
||||||
// Créer un objet de type FileList similaire pour la compatibilité
|
</p>
|
||||||
const dataTransfer = new DataTransfer();
|
)}
|
||||||
dataTransfer.items.add(file);
|
<iframe
|
||||||
onChange(dataTransfer.files);
|
src={masterFileUrl}
|
||||||
}}
|
title={field.label || 'Document'}
|
||||||
errorMsg={
|
className="w-full rounded border border-gray-200 bg-white"
|
||||||
errors[field.id]
|
style={{ height: '520px', border: 'none' }}
|
||||||
? field.required
|
/>
|
||||||
? `${field.label} est requis`
|
</div>
|
||||||
: 'Champ invalide'
|
) : (
|
||||||
: ''
|
<FileUpload
|
||||||
}
|
selectionMessage={field.label}
|
||||||
/>
|
required={field.required}
|
||||||
|
uploadedFileName={value ? value[0]?.name : null}
|
||||||
|
onFileSelect={(file) => {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
onChange(dataTransfer.files);
|
||||||
|
}}
|
||||||
|
errorMsg={
|
||||||
|
errors[field.id]
|
||||||
|
? field.required
|
||||||
|
? `${field.label} est requis`
|
||||||
|
: 'Champ invalide'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -406,7 +413,14 @@ export default function FormRenderer({
|
|||||||
control={control}
|
control={control}
|
||||||
rules={{ required: field.required }}
|
rules={{ required: field.required }}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<div>
|
<div
|
||||||
|
className={
|
||||||
|
masterFile
|
||||||
|
? 'mt-3 flex justify-end'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={masterFile ? 'w-full max-w-xs' : 'w-full'}>
|
||||||
<SignatureField
|
<SignatureField
|
||||||
label={field.label}
|
label={field.label}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
@ -415,6 +429,8 @@ export default function FormRenderer({
|
|||||||
backgroundColor={field.backgroundColor || '#ffffff'}
|
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||||
penColor={field.penColor || '#000000'}
|
penColor={field.penColor || '#000000'}
|
||||||
penWidth={field.penWidth || 2}
|
penWidth={field.penWidth || 2}
|
||||||
|
displayWidth={masterFile ? 260 : 400}
|
||||||
|
displayHeight={masterFile ? 120 : 200}
|
||||||
/>
|
/>
|
||||||
{errors[field.id] && (
|
{errors[field.id] && (
|
||||||
<p className="text-red-500 text-sm mt-1">
|
<p className="text-red-500 text-sm mt-1">
|
||||||
@ -423,6 +439,7 @@ export default function FormRenderer({
|
|||||||
: 'Champ invalide'}
|
: 'Champ invalide'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -177,6 +177,8 @@ export default function FormTemplateBuilder({
|
|||||||
initialData,
|
initialData,
|
||||||
groups,
|
groups,
|
||||||
isEditing,
|
isEditing,
|
||||||
|
masterFile = null,
|
||||||
|
onMasterFileUpload,
|
||||||
}) {
|
}) {
|
||||||
const [formConfig, setFormConfig] = useState({
|
const [formConfig, setFormConfig] = useState({
|
||||||
id: initialData?.id || 0,
|
id: initialData?.id || 0,
|
||||||
@ -186,7 +188,9 @@ export default function FormTemplateBuilder({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [selectedGroups, setSelectedGroups] = useState(
|
const [selectedGroups, setSelectedGroups] = useState(
|
||||||
initialData?.groups?.map((g) => g.id) || []
|
initialData?.groups?.map((g) =>
|
||||||
|
typeof g === 'object' && g !== null ? g.id : g
|
||||||
|
) || []
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||||
@ -209,7 +213,11 @@ export default function FormTemplateBuilder({
|
|||||||
submitLabel: 'Envoyer',
|
submitLabel: 'Envoyer',
|
||||||
fields: initialData.formMasterData?.fields || [],
|
fields: initialData.formMasterData?.fields || [],
|
||||||
});
|
});
|
||||||
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
setSelectedGroups(
|
||||||
|
initialData.groups?.map((g) =>
|
||||||
|
typeof g === 'object' && g !== null ? g.id : g
|
||||||
|
) || []
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
@ -256,6 +264,21 @@ export default function FormTemplateBuilder({
|
|||||||
const handleFieldSubmit = (data, currentField, editIndex) => {
|
const handleFieldSubmit = (data, currentField, editIndex) => {
|
||||||
const isHeadingType = data.type.startsWith('heading');
|
const isHeadingType = data.type.startsWith('heading');
|
||||||
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
||||||
|
const effectiveMasterFile = masterFile || currentField?.masterFileToUpload;
|
||||||
|
|
||||||
|
if (currentField?.masterFileToUpload && onMasterFileUpload) {
|
||||||
|
onMasterFileUpload(currentField.masterFileToUpload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un champ fichier nécessite un document source déjà uploadé.
|
||||||
|
if (data.type === 'file' && !effectiveMasterFile) {
|
||||||
|
setSaveMessage({
|
||||||
|
type: 'error',
|
||||||
|
text:
|
||||||
|
'Veuillez d\'abord uploader le document du formulaire avant d\'ajouter un champ fichier.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fieldData = {
|
const fieldData = {
|
||||||
...data,
|
...data,
|
||||||
@ -653,7 +676,7 @@ export default function FormTemplateBuilder({
|
|||||||
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
||||||
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
||||||
{formConfig.fields.length > 0 ? (
|
{formConfig.fields.length > 0 ? (
|
||||||
<FormRenderer formConfig={formConfig} />
|
<FormRenderer formConfig={formConfig} masterFile={masterFile} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-500 italic text-center">
|
<p className="text-gray-500 italic text-center">
|
||||||
Ajoutez des champs pour voir l'aperçu
|
Ajoutez des champs pour voir l'aperçu
|
||||||
@ -668,6 +691,8 @@ export default function FormTemplateBuilder({
|
|||||||
isOpen={showAddFieldModal}
|
isOpen={showAddFieldModal}
|
||||||
onClose={() => setShowAddFieldModal(false)}
|
onClose={() => setShowAddFieldModal(false)}
|
||||||
onSubmit={handleFieldSubmit}
|
onSubmit={handleFieldSubmit}
|
||||||
|
hasMasterFile={Boolean(masterFile)}
|
||||||
|
onMasterFileUpload={onMasterFileUpload}
|
||||||
editingField={
|
editingField={
|
||||||
editingIndex >= 0
|
editingIndex >= 0
|
||||||
? formConfig.fields[editingIndex]
|
? formConfig.fields[editingIndex]
|
||||||
|
|||||||
@ -11,6 +11,8 @@ const SignatureField = ({
|
|||||||
backgroundColor = '#ffffff',
|
backgroundColor = '#ffffff',
|
||||||
penColor = '#000000',
|
penColor = '#000000',
|
||||||
penWidth = 2,
|
penWidth = 2,
|
||||||
|
displayWidth = 400,
|
||||||
|
displayHeight = 200,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@ -29,9 +31,6 @@ const SignatureField = ({
|
|||||||
|
|
||||||
// Support High DPI / Retina displays
|
// Support High DPI / Retina displays
|
||||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
const displayWidth = 400;
|
|
||||||
const displayHeight = 200;
|
|
||||||
|
|
||||||
// Ajuster la taille physique du canvas pour la haute résolution
|
// Ajuster la taille physique du canvas pour la haute résolution
|
||||||
canvas.width = displayWidth * devicePixelRatio;
|
canvas.width = displayWidth * devicePixelRatio;
|
||||||
canvas.height = displayHeight * devicePixelRatio;
|
canvas.height = displayHeight * devicePixelRatio;
|
||||||
@ -56,7 +55,7 @@ const SignatureField = ({
|
|||||||
context.lineCap = 'round';
|
context.lineCap = 'round';
|
||||||
context.lineJoin = 'round';
|
context.lineJoin = 'round';
|
||||||
context.globalCompositeOperation = 'source-over';
|
context.globalCompositeOperation = 'source-over';
|
||||||
}, [backgroundColor, penColor, penWidth]);
|
}, [backgroundColor, penColor, penWidth, displayWidth, displayHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeCanvas();
|
initializeCanvas();
|
||||||
@ -226,11 +225,12 @@ const SignatureField = ({
|
|||||||
setCurrentPath('');
|
setCurrentPath('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifier le parent du changement avec SVG
|
// Notifier le parent du changement avec PNG pour garantir
|
||||||
|
// la compatibilite de rendu cote backend/PDF.
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
const canvas = canvasRef.current;
|
||||||
const svgData = generateSVG(newPaths);
|
const pngData = canvas ? canvas.toDataURL('image/png') : '';
|
||||||
onChange(svgData);
|
onChange(pngData);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDrawing, onChange, svgPaths, currentPath]
|
[isDrawing, onChange, svgPaths, currentPath]
|
||||||
@ -238,7 +238,7 @@ const SignatureField = ({
|
|||||||
|
|
||||||
// Générer le SVG à partir des paths
|
// Générer le SVG à partir des paths
|
||||||
const generateSVG = (paths) => {
|
const generateSVG = (paths) => {
|
||||||
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
const svgContent = `<svg width="${displayWidth}" height="${displayHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||||
${paths
|
${paths
|
||||||
.map(
|
.map(
|
||||||
@ -257,9 +257,6 @@ const SignatureField = ({
|
|||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
// Effacer en tenant compte des dimensions d'affichage
|
// Effacer en tenant compte des dimensions d'affichage
|
||||||
const displayWidth = 400;
|
|
||||||
const displayHeight = 200;
|
|
||||||
|
|
||||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||||
context.fillStyle = backgroundColor;
|
context.fillStyle = backgroundColor;
|
||||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
@ -273,6 +270,14 @@ const SignatureField = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hintText = readOnly
|
||||||
|
? isEmpty
|
||||||
|
? 'Aucune signature'
|
||||||
|
: 'Signature'
|
||||||
|
: isEmpty
|
||||||
|
? 'Signez dans la zone ci-dessus'
|
||||||
|
: 'Signature capturée';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signature-field">
|
<div className="signature-field">
|
||||||
{label && (
|
{label && (
|
||||||
@ -282,7 +287,7 @@ const SignatureField = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
<div className="border border-gray-300 rounded-lg p-3 bg-gray-50">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`border border-gray-200 bg-white rounded touch-none ${
|
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||||
@ -307,16 +312,8 @@ const SignatureField = ({
|
|||||||
onTouchEnd={readOnly ? undefined : stopDrawing}
|
onTouchEnd={readOnly ? undefined : stopDrawing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-3">
|
<div className="flex justify-between items-center mt-2">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">{hintText}</div>
|
||||||
{readOnly
|
|
||||||
? isEmpty
|
|
||||||
? 'Aucune signature'
|
|
||||||
: 'Signature'
|
|
||||||
: isEmpty
|
|
||||||
? 'Signez dans la zone ci-dessus'
|
|
||||||
: 'Signature capturée'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -34,6 +34,44 @@ export default function DynamicFormsList({
|
|||||||
const [formsValidation, setFormsValidation] = useState({});
|
const [formsValidation, setFormsValidation] = useState({});
|
||||||
const fileInputRefs = React.useRef({});
|
const fileInputRefs = React.useRef({});
|
||||||
|
|
||||||
|
const extractResponses = (data, maxDepth = 8) => {
|
||||||
|
let current = data;
|
||||||
|
for (let i = 0; i < maxDepth; i += 1) {
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
if (current.responses && typeof current.responses === 'object') {
|
||||||
|
current = current.responses;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
|
||||||
|
const cleaned = { ...current };
|
||||||
|
delete cleaned.formId;
|
||||||
|
delete cleaned.id;
|
||||||
|
delete cleaned.templateId;
|
||||||
|
delete cleaned.responses;
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLocalCompletion = (templateId) => {
|
||||||
|
if (formsValidation[templateId] === true) return true;
|
||||||
|
|
||||||
|
const localData = formsData[templateId];
|
||||||
|
if (localData instanceof FormData) return true;
|
||||||
|
if (localData && typeof localData === 'object') {
|
||||||
|
return Object.keys(localData).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedResponses = existingResponses[templateId];
|
||||||
|
return !!(
|
||||||
|
savedResponses &&
|
||||||
|
typeof savedResponses === 'object' &&
|
||||||
|
Object.keys(savedResponses).length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Initialiser les données avec les réponses existantes
|
// Initialiser les données avec les réponses existantes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
// Initialisation complète de formsValidation et formsData pour chaque template
|
||||||
@ -90,11 +128,7 @@ export default function DynamicFormsList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
||||||
const allFormsValid = schoolFileTemplates.every(
|
const allFormsValid = schoolFileTemplates.every(
|
||||||
(tpl) =>
|
(tpl) => tpl.isValidated === true || hasLocalCompletion(tpl.id)
|
||||||
tpl.isValidated === true ||
|
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
onValidationChange(allFormsValid);
|
onValidationChange(allFormsValid);
|
||||||
@ -113,10 +147,12 @@ export default function DynamicFormsList({
|
|||||||
try {
|
try {
|
||||||
logger.debug('Soumission du formulaire:', { templateId, formData });
|
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||||
|
|
||||||
|
const normalizedResponses = extractResponses(formData);
|
||||||
|
|
||||||
// Sauvegarder les données du formulaire
|
// Sauvegarder les données du formulaire
|
||||||
setFormsData((prev) => ({
|
setFormsData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[templateId]: formData,
|
[templateId]: normalizedResponses,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Marquer le formulaire comme complété
|
// Marquer le formulaire comme complété
|
||||||
@ -145,13 +181,7 @@ export default function DynamicFormsList({
|
|||||||
* Vérifie si un formulaire est complété
|
* Vérifie si un formulaire est complété
|
||||||
*/
|
*/
|
||||||
const isFormCompleted = (templateId) => {
|
const isFormCompleted = (templateId) => {
|
||||||
return (
|
return hasLocalCompletion(templateId);
|
||||||
formsValidation[templateId] === true ||
|
|
||||||
(formsData[templateId] &&
|
|
||||||
Object.keys(formsData[templateId]).length > 0) ||
|
|
||||||
(existingResponses[templateId] &&
|
|
||||||
Object.keys(existingResponses[templateId]).length > 0)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -229,13 +259,7 @@ export default function DynamicFormsList({
|
|||||||
{
|
{
|
||||||
schoolFileTemplates.filter((tpl) => {
|
schoolFileTemplates.filter((tpl) => {
|
||||||
// Validé ou complété localement
|
// Validé ou complété localement
|
||||||
return (
|
return tpl.isValidated === true || hasLocalCompletion(tpl.id);
|
||||||
tpl.isValidated === true ||
|
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
{' / '}
|
{' / '}
|
||||||
@ -247,14 +271,10 @@ export default function DynamicFormsList({
|
|||||||
// Helper pour état
|
// Helper pour état
|
||||||
const getState = (tpl) => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0; // validé
|
if (tpl.isValidated === true) return 0; // validé
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
if (isCompletedLocally) return 1; // complété (en attente de traitement)
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
if (tpl.isValidated === false) return 2; // refusé
|
||||||
(existingResponses[tpl.id] &&
|
return 3; // à compléter
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
if (isCompletedLocally) return 1; // complété/en attente
|
|
||||||
return 2; // à compléter/refusé
|
|
||||||
};
|
};
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
||||||
return getState(a) - getState(b);
|
return getState(a) - getState(b);
|
||||||
@ -268,12 +288,7 @@ export default function DynamicFormsList({
|
|||||||
typeof tpl.isValidated === 'boolean'
|
typeof tpl.isValidated === 'boolean'
|
||||||
? tpl.isValidated
|
? tpl.isValidated
|
||||||
: undefined;
|
: undefined;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Statut d'affichage
|
// Statut d'affichage
|
||||||
let statusLabel = '';
|
let statusLabel = '';
|
||||||
@ -300,31 +315,17 @@ export default function DynamicFormsList({
|
|||||||
: textClass;
|
: textClass;
|
||||||
canEdit = false;
|
canEdit = false;
|
||||||
} else if (isValidated === false) {
|
} else if (isValidated === false) {
|
||||||
if (isCompletedLocally) {
|
statusLabel = 'Refusé';
|
||||||
statusLabel = 'Complété';
|
statusColor = 'red';
|
||||||
statusColor = 'orange';
|
icon = <Hourglass className="w-5 h-5 text-red-500" />;
|
||||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
borderClass = isActive
|
||||||
borderClass = isActive
|
? 'border border-red-300'
|
||||||
? 'border border-orange-300'
|
: 'border border-red-200';
|
||||||
: 'border border-orange-200';
|
textClass = isActive
|
||||||
textClass = isActive
|
? 'text-red-900 font-semibold'
|
||||||
? 'text-orange-900 font-semibold'
|
: 'text-red-700';
|
||||||
: 'text-orange-700';
|
canEdit = true;
|
||||||
canEdit = true;
|
|
||||||
} else {
|
|
||||||
statusLabel = 'Refusé';
|
|
||||||
statusColor = 'red';
|
|
||||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
|
||||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
|
||||||
borderClass = isActive
|
|
||||||
? 'border border-red-300'
|
|
||||||
: 'border border-red-200';
|
|
||||||
textClass = isActive
|
|
||||||
? 'text-red-900 font-semibold'
|
|
||||||
: 'text-red-700';
|
|
||||||
canEdit = true;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (isCompletedLocally) {
|
if (isCompletedLocally) {
|
||||||
statusLabel = 'Complété';
|
statusLabel = 'Complété';
|
||||||
@ -405,17 +406,17 @@ export default function DynamicFormsList({
|
|||||||
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
||||||
Validé
|
Validé
|
||||||
</span>
|
</span>
|
||||||
) : (formsData[currentTemplate.id] &&
|
) : currentTemplate.isValidated === false ? (
|
||||||
Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||||
(existingResponses[currentTemplate.id] &&
|
Refusé
|
||||||
Object.keys(existingResponses[currentTemplate.id]).length >
|
</span>
|
||||||
0) ? (
|
) : hasLocalCompletion(currentTemplate.id) ? (
|
||||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
||||||
Complété
|
Complété
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-sm font-semibold">
|
||||||
Refusé
|
En attente
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -430,14 +431,10 @@ export default function DynamicFormsList({
|
|||||||
// Trouver l'index du template courant dans la liste triée
|
// Trouver l'index du template courant dans la liste triée
|
||||||
const getState = (tpl) => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0;
|
if (tpl.isValidated === true) return 0;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||||
(formsData[tpl.id] &&
|
|
||||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
|
||||||
(existingResponses[tpl.id] &&
|
|
||||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
|
||||||
);
|
|
||||||
if (isCompletedLocally) return 1;
|
if (isCompletedLocally) return 1;
|
||||||
return 2;
|
if (tpl.isValidated === false) return 2;
|
||||||
|
return 3;
|
||||||
};
|
};
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort(
|
const sortedTemplates = [...schoolFileTemplates].sort(
|
||||||
(a, b) => getState(a) - getState(b)
|
(a, b) => getState(a) - getState(b)
|
||||||
@ -469,8 +466,11 @@ export default function DynamicFormsList({
|
|||||||
submitLabel:
|
submitLabel:
|
||||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||||
}}
|
}}
|
||||||
|
masterFile={
|
||||||
|
currentTemplate.master_file_url || currentTemplate.file || null
|
||||||
|
}
|
||||||
initialValues={
|
initialValues={
|
||||||
formsData[currentTemplate.id] ||
|
extractResponses(formsData[currentTemplate.id]) ||
|
||||||
existingResponses[currentTemplate.id] ||
|
existingResponses[currentTemplate.id] ||
|
||||||
{}
|
{}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,10 +53,10 @@ const FilesModal = ({
|
|||||||
.then((parentFiles) => {
|
.then((parentFiles) => {
|
||||||
// Construct the categorized files list
|
// Construct the categorized files list
|
||||||
const categorizedFiles = {
|
const categorizedFiles = {
|
||||||
registrationFile: selectedRegisterForm.registration_file
|
registrationFile: selectedRegisterForm.student?.id
|
||||||
? {
|
? {
|
||||||
name: 'Fiche élève',
|
name: 'Fiche élève',
|
||||||
url: getSecureFileUrl(selectedRegisterForm.registration_file),
|
url: `/api/generate-pdf?studentId=${selectedRegisterForm.student.id}`,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
fusionFile: selectedRegisterForm.fusion_file
|
fusionFile: selectedRegisterForm.fusion_file
|
||||||
|
|||||||
@ -285,6 +285,29 @@ export default function InscriptionFormShared({
|
|||||||
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aplatit les structures de type { responses: { responses: {...} } }
|
||||||
|
// pour ne conserver que les reponses de champs (ex: sign).
|
||||||
|
const extractResponses = (data, maxDepth = 8) => {
|
||||||
|
let current = data;
|
||||||
|
for (let i = 0; i < maxDepth; i += 1) {
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
if (current.responses && typeof current.responses === 'object') {
|
||||||
|
current = current.responses;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== 'object') return {};
|
||||||
|
const cleaned = { ...current };
|
||||||
|
delete cleaned.formId;
|
||||||
|
delete cleaned.id;
|
||||||
|
delete cleaned.templateId;
|
||||||
|
delete cleaned.responses;
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedResponses = extractResponses(formData);
|
||||||
|
|
||||||
// Construire la structure complète avec la configuration et les réponses
|
// Construire la structure complète avec la configuration et les réponses
|
||||||
const formTemplateData = {
|
const formTemplateData = {
|
||||||
id: currentTemplate.id,
|
id: currentTemplate.id,
|
||||||
@ -300,17 +323,17 @@ export default function InscriptionFormShared({
|
|||||||
).map((field) => ({
|
).map((field) => ({
|
||||||
...field,
|
...field,
|
||||||
...(field.type === 'checkbox'
|
...(field.type === 'checkbox'
|
||||||
? { checked: formData[field.id] || false }
|
? { checked: normalizedResponses[field.id] || false }
|
||||||
: {}),
|
: {}),
|
||||||
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
...(field.type === 'radio'
|
||||||
...(field.type === 'text' ||
|
? { selected: normalizedResponses[field.id] }
|
||||||
field.type === 'textarea' ||
|
: {}),
|
||||||
field.type === 'email'
|
...(field.id
|
||||||
? { value: formData[field.id] || '' }
|
? { value: normalizedResponses[field.id] ?? field.value ?? '' }
|
||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||||
responses: formData,
|
responses: normalizedResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||||
@ -326,7 +349,7 @@ export default function InscriptionFormShared({
|
|||||||
logger.debug("Réponse de l'API:", result);
|
logger.debug("Réponse de l'API:", result);
|
||||||
|
|
||||||
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
||||||
let newResponses = formData;
|
let newResponses = normalizedResponses;
|
||||||
if (
|
if (
|
||||||
result &&
|
result &&
|
||||||
result.data &&
|
result.data &&
|
||||||
|
|||||||
@ -152,7 +152,11 @@ export default function ValidateSubscription({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const allTemplates = [
|
const allTemplates = [
|
||||||
{ name: 'Fiche élève', file: student_file, type: 'main' },
|
{
|
||||||
|
name: 'Fiche élève',
|
||||||
|
file: `/api/generate-pdf?studentId=${studentId}`,
|
||||||
|
type: 'main',
|
||||||
|
},
|
||||||
...schoolFileTemplates.map((template) => ({
|
...schoolFileTemplates.map((template) => ({
|
||||||
name: template.name || 'Document scolaire',
|
name: template.name || 'Document scolaire',
|
||||||
file: template.file,
|
file: template.file,
|
||||||
@ -213,7 +217,11 @@ export default function ValidateSubscription({
|
|||||||
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
||||||
</h3>
|
</h3>
|
||||||
<iframe
|
<iframe
|
||||||
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
|
src={
|
||||||
|
allTemplates[currentTemplateIndex].type === 'main'
|
||||||
|
? allTemplates[currentTemplateIndex].file
|
||||||
|
: getSecureFileUrl(allTemplates[currentTemplateIndex].file)
|
||||||
|
}
|
||||||
title={
|
title={
|
||||||
allTemplates[currentTemplateIndex].type === 'main'
|
allTemplates[currentTemplateIndex].type === 'main'
|
||||||
? 'Document Principal'
|
? 'Document Principal'
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
|
||||||
import {
|
import {
|
||||||
// GET
|
// GET
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
@ -32,6 +32,7 @@ import CheckBox from '@/components/Form/CheckBox';
|
|||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import InputText from '@/components/Form/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
import { FE_ADMIN_STRUCTURE_FORM_BUILDER_URL } from '@/utils/Url';
|
||||||
|
|
||||||
function getItemBgColor(type, selected, forceTheme = false) {
|
function getItemBgColor(type, selected, forceTheme = false) {
|
||||||
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
||||||
@ -200,7 +201,7 @@ export default function FilesGroupsManagement({
|
|||||||
const [parentFiles, setParentFileMasters] = useState([]);
|
const [parentFiles, setParentFileMasters] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [fileToEdit, setFileToEdit] = useState(null);
|
const [fileToEdit, setFileToEdit] = useState(null);
|
||||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||||
@ -226,10 +227,8 @@ export default function FilesGroupsManagement({
|
|||||||
const handleDocDropdownSelect = (type) => {
|
const handleDocDropdownSelect = (type) => {
|
||||||
setIsDocDropdownOpen(false);
|
setIsDocDropdownOpen(false);
|
||||||
if (type === 'formulaire') {
|
if (type === 'formulaire') {
|
||||||
// Ouvre la modale unique en mode création
|
const groupParam = selectedGroupId ? `?groupId=${selectedGroupId}` : '';
|
||||||
setIsEditing(false);
|
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}${groupParam}`);
|
||||||
setFileToEdit(null);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
} else if (type === 'formulaire_existant') {
|
} else if (type === 'formulaire_existant') {
|
||||||
setIsFileUploadPopupOpen(true);
|
setIsFileUploadPopupOpen(true);
|
||||||
setFileToEdit({});
|
setFileToEdit({});
|
||||||
@ -329,28 +328,29 @@ export default function FilesGroupsManagement({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editTemplateMaster = (file) => {
|
const editTemplateMaster = (file) => {
|
||||||
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
|
const isDynamic =
|
||||||
if (
|
file.formMasterData &&
|
||||||
!file.formMasterData ||
|
Array.isArray(file.formMasterData.fields) &&
|
||||||
!Array.isArray(file.formMasterData.fields) ||
|
file.formMasterData.fields.length > 0;
|
||||||
file.formMasterData.fields.length === 0
|
|
||||||
) {
|
if (isDynamic) {
|
||||||
setFileToEdit(file);
|
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}?id=${file.id}`);
|
||||||
setIsFileUploadPopupOpen(true);
|
|
||||||
setIsEditing(true);
|
|
||||||
} else {
|
} else {
|
||||||
setIsEditing(true);
|
|
||||||
setFileToEdit(file);
|
setFileToEdit(file);
|
||||||
setIsModalOpen(true);
|
setIsEditing(true);
|
||||||
|
setIsFileUploadPopupOpen(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSchoolFileMaster = ({
|
const handleCreateSchoolFileMaster = (
|
||||||
name,
|
{
|
||||||
group_ids,
|
name,
|
||||||
formMasterData,
|
group_ids,
|
||||||
file,
|
formMasterData,
|
||||||
}) => {
|
file,
|
||||||
|
},
|
||||||
|
onCreated
|
||||||
|
) => {
|
||||||
// Toujours envoyer en FormData, même sans fichier
|
// Toujours envoyer en FormData, même sans fichier
|
||||||
const dataToSend = new FormData();
|
const dataToSend = new FormData();
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
@ -379,12 +379,12 @@ export default function FilesGroupsManagement({
|
|||||||
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
|
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
|
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été créé avec succès.`,
|
`Le formulaire "${name}" a été créé avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
'Succès'
|
'Succès'
|
||||||
);
|
);
|
||||||
|
if (onCreated) onCreated(data);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Error creating form:', error);
|
logger.error('Error creating form:', error);
|
||||||
@ -460,7 +460,6 @@ export default function FilesGroupsManagement({
|
|||||||
setSchoolFileMasters((prevFichiers) =>
|
setSchoolFileMasters((prevFichiers) =>
|
||||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||||
);
|
);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
@ -495,7 +494,6 @@ export default function FilesGroupsManagement({
|
|||||||
setSchoolFileMasters((prevFichiers) =>
|
setSchoolFileMasters((prevFichiers) =>
|
||||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||||
);
|
);
|
||||||
setIsModalOpen(false);
|
|
||||||
showNotification(
|
showNotification(
|
||||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||||
'success',
|
'success',
|
||||||
@ -888,13 +886,6 @@ export default function FilesGroupsManagement({
|
|||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utilitaire pour ouvrir la modale FormTemplateBuilder (création ou édition)
|
|
||||||
const openFormBuilderModal = (editing = false, initialData = null) => {
|
|
||||||
setIsEditing(editing);
|
|
||||||
setFileToEdit(initialData);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Aide optionnelle */}
|
{/* Aide optionnelle */}
|
||||||
@ -1094,37 +1085,6 @@ export default function FilesGroupsManagement({
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Modals pour création/édition d'un formulaire dynamique */}
|
|
||||||
<Modal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
setIsOpen={(isOpen) => {
|
|
||||||
setIsModalOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setFileToEdit(null);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
isEditing
|
|
||||||
? 'Modification du formulaire'
|
|
||||||
: 'Créer un formulaire personnalisé'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<FormTemplateBuilder
|
|
||||||
onSave={(data) => {
|
|
||||||
(isEditing
|
|
||||||
? handleEditSchoolFileMaster
|
|
||||||
: handleCreateSchoolFileMaster)(data);
|
|
||||||
setIsModalOpen(false);
|
|
||||||
}}
|
|
||||||
initialData={isEditing ? fileToEdit : undefined}
|
|
||||||
groups={groups}
|
|
||||||
isEditing={isEditing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Popup pour création/édition d'un formulaire d'école déjà existant */}
|
{/* Popup pour création/édition d'un formulaire d'école déjà existant */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isFileUploadPopupOpen}
|
isOpen={isFileUploadPopupOpen}
|
||||||
@ -1262,11 +1222,13 @@ export default function FilesGroupsManagement({
|
|||||||
!fileToEdit?.file
|
!fileToEdit?.file
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
handleCreateSchoolFileMaster({
|
handleCreateSchoolFileMaster(
|
||||||
name: fileToEdit.name,
|
{
|
||||||
group_ids: fileToEdit.groups,
|
name: fileToEdit.name,
|
||||||
file: fileToEdit.file,
|
group_ids: fileToEdit.groups,
|
||||||
});
|
file: fileToEdit.file,
|
||||||
|
}
|
||||||
|
);
|
||||||
setIsFileUploadPopupOpen(false);
|
setIsFileUploadPopupOpen(false);
|
||||||
setFileToEdit(null);
|
setFileToEdit(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from '@/utils/logger';
|
||||||
import { getToken } from 'next-auth/jwt';
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
@ -27,7 +28,6 @@ export default async function handler(req, res) {
|
|||||||
const backendRes = await fetch(backendUrl, {
|
const backendRes = await fetch(backendUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.token}`,
|
Authorization: `Bearer ${token.token}`,
|
||||||
Connection: 'close',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,7 +48,8 @@ export default async function handler(req, res) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.error('Download proxy error:', error);
|
||||||
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
Front-End/src/pages/api/generate-pdf.js
Normal file
58
Front-End/src/pages/api/generate-pdf.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken({
|
||||||
|
req,
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
cookieName: 'n3wtschool_session_token',
|
||||||
|
});
|
||||||
|
if (!token?.token) {
|
||||||
|
return res.status(401).json({ error: 'Non authentifié' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { studentId } = req.query;
|
||||||
|
if (!studentId) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Le paramètre "studentId" est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendUrl = `${BACKEND_URL}/Subscriptions/registerForms/${encodeURIComponent(studentId)}/pdf`;
|
||||||
|
|
||||||
|
const backendRes = await fetch(backendUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.token}`,
|
||||||
|
Connection: 'close',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backendRes.ok) {
|
||||||
|
return res.status(backendRes.status).json({
|
||||||
|
error: `Erreur backend: ${backendRes.status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
backendRes.headers.get('content-type') || 'application/pdf';
|
||||||
|
const contentDisposition = backendRes.headers.get('content-disposition');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
if (contentDisposition) {
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||||
|
return res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: 'Erreur lors de la génération du PDF' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,6 +54,7 @@ export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
|
|||||||
// GESTION EMAIL
|
// GESTION EMAIL
|
||||||
export const BE_GESTIONEMAIL_SEND_EMAIL_URL = `${BASE_URL}/GestionEmail/send-email/`;
|
export const BE_GESTIONEMAIL_SEND_EMAIL_URL = `${BASE_URL}/GestionEmail/send-email/`;
|
||||||
export const BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionEmail/search-recipients`;
|
export const BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionEmail/search-recipients`;
|
||||||
|
export const BE_GESTIONEMAIL_SEND_FEEDBACK_URL = `${BASE_URL}/GestionEmail/send-feedback/`;
|
||||||
|
|
||||||
// GESTION MESSAGERIE
|
// GESTION MESSAGERIE
|
||||||
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
|
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
|
||||||
@ -102,6 +103,8 @@ export const FE_ADMIN_CLASSES_URL = '/admin/classes';
|
|||||||
export const FE_ADMIN_STRUCTURE_URL = '/admin/structure';
|
export const FE_ADMIN_STRUCTURE_URL = '/admin/structure';
|
||||||
export const FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL =
|
export const FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL =
|
||||||
'/admin/structure/SchoolClassManagement';
|
'/admin/structure/SchoolClassManagement';
|
||||||
|
export const FE_ADMIN_STRUCTURE_FORM_BUILDER_URL =
|
||||||
|
'/admin/structure/FormBuilder';
|
||||||
|
|
||||||
//ADMIN/DIRECTORY URL
|
//ADMIN/DIRECTORY URL
|
||||||
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';
|
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';
|
||||||
@ -123,6 +126,9 @@ export const FE_ADMIN_SETTINGS_URL = '/admin/settings';
|
|||||||
//ADMIN/MESSAGERIE URL
|
//ADMIN/MESSAGERIE URL
|
||||||
export const FE_ADMIN_MESSAGERIE_URL = '/admin/messagerie';
|
export const FE_ADMIN_MESSAGERIE_URL = '/admin/messagerie';
|
||||||
|
|
||||||
|
//ADMIN/FEEDBACK URL
|
||||||
|
export const FE_ADMIN_FEEDBACK_URL = '/admin/feedback';
|
||||||
|
|
||||||
// PARENT HOME
|
// PARENT HOME
|
||||||
export const FE_PARENTS_HOME_URL = '/parents';
|
export const FE_PARENTS_HOME_URL = '/parents';
|
||||||
export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie';
|
export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie';
|
||||||
|
|||||||
@ -10,6 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
export const getSecureFileUrl = (filePath) => {
|
export const getSecureFileUrl = (filePath) => {
|
||||||
if (!filePath) return null;
|
if (!filePath) return null;
|
||||||
|
if (typeof filePath !== 'string') return null;
|
||||||
|
|
||||||
|
// URL deja proxifiee: la reutiliser telle quelle.
|
||||||
|
if (filePath.startsWith('/api/download?')) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
// Si c'est une URL absolue, extraire le chemin /data/...
|
// Si c'est une URL absolue, extraire le chemin /data/...
|
||||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user