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.validators
|
||||
|
||||
@ -14,7 +14,7 @@ class ProfileSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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}}
|
||||
|
||||
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
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -137,6 +137,12 @@ class ServeFileView(APIView):
|
||||
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
|
||||
if file_path.startswith('/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 django.contrib.postgres.fields
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
SendEmailView, search_recipients
|
||||
SendEmailView, search_recipients, SendFeedbackView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||
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.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from Auth.models import Profile, ProfileRole
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -119,3 +120,84 @@ def search_recipients(request):
|
||||
})
|
||||
|
||||
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.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
|
||||
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
|
||||
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.db.models.deletion
|
||||
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
|
||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||
('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)),
|
||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
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(
|
||||
name='Teacher',
|
||||
fields=[
|
||||
|
||||
@ -73,12 +73,15 @@ class SpecialityListCreateView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
school_year = request.GET.get('school_year', None)
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
specialities_list = getAllObjects(Speciality)
|
||||
if establishment_id:
|
||||
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)
|
||||
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
|
||||
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 django.db.models.deletion
|
||||
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
|
||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
@ -90,6 +92,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_update', models.DateTimeField(auto_now=True)),
|
||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||
('notes', models.CharField(blank=True, max_length=200)),
|
||||
@ -209,6 +212,8 @@ class Migration(migrations.Migration):
|
||||
('score', models.IntegerField(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)),
|
||||
('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')),
|
||||
('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')},
|
||||
},
|
||||
),
|
||||
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 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:
|
||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||
extension = os.path.splitext(old_filename)[1]
|
||||
@ -438,16 +440,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_filename = f"{self.name}.pdf"
|
||||
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||
self.file.save(pdf_filename, pdf_file, save=False)
|
||||
# IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
|
||||
# rester le document source uploadé (PDF/image). La génération du PDF final
|
||||
# est faite au niveau des templates (par élève), pas sur le master.
|
||||
|
||||
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)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
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):
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
|
||||
@ -39,10 +39,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||
|
||||
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileMaster
|
||||
fields = '__all__'
|
||||
|
||||
def get_file_url(self, obj):
|
||||
return obj.file.url if obj.file else None
|
||||
|
||||
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
class Meta:
|
||||
@ -52,6 +57,7 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
master_file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileTemplate
|
||||
@ -61,6 +67,12 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
# Retourne l'URL complète du fichier si disponible
|
||||
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):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
@ -399,10 +411,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
|
||||
class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
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):
|
||||
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
||||
@ -412,6 +425,9 @@ class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
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):
|
||||
student = StudentByParentSerializer(many=False, required=True)
|
||||
|
||||
|
||||
@ -1,228 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>
|
||||
Fiche élève — {{ student.last_name }} {{ student.first_name }}
|
||||
</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
margin: 1.5cm 2cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #222;
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.container {
|
||||
|
||||
/* ── Header ── */
|
||||
.header-table {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 12px;
|
||||
position: relative;
|
||||
.header-table td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header-left {
|
||||
width: 80%;
|
||||
}
|
||||
.header-right {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
}
|
||||
.school-name {
|
||||
font-size: 10pt;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
color: #064e3b;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 11pt;
|
||||
color: #059669;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.header-line {
|
||||
border: none;
|
||||
border-top: 3px solid #059669;
|
||||
margin: 12px 0 20px 0;
|
||||
}
|
||||
.photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #4CAF50;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #059669;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
margin-bottom: 32px; /* Espacement augmenté entre les sections */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15pt;
|
||||
.section-header {
|
||||
background-color: #059669;
|
||||
color: #ffffff;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 18px; /* Espacement sous le titre de section */
|
||||
border-bottom: 1px solid #4CAF50;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f3f3f3;
|
||||
font-weight: bold;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
.label-cell {
|
||||
font-weight: bold;
|
||||
width: 30%;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.value-cell {
|
||||
width: 70%;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
.signature-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 0;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.subsection-title {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
margin: 8px 0 4px 0;
|
||||
font-size: 10pt;
|
||||
color: #064e3b;
|
||||
font-weight: bold;
|
||||
padding: 6px 0 2px 0;
|
||||
margin: 8px 0 4px 0;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
table.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
{% 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" />
|
||||
|
||||
<!-- ═══════ HEADER ═══════ -->
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td class="header-left">
|
||||
{% if establishment %}
|
||||
<p class="school-name">{{ establishment.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="title">Fiche Élèves</h1>
|
||||
<!-- prettier-ignore -->
|
||||
<p class="subtitle">{{ student.last_name }} {{ student.first_name }}{% if school_year %} — {{ school_year }}{% endif %}</p>
|
||||
</td>
|
||||
<td class="header-right">
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo" class="photo" />
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr class="header-line" />
|
||||
|
||||
<!-- Élève -->
|
||||
<!-- ═══════ ÉLÈVE ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">ÉLÈVE</div>
|
||||
<table>
|
||||
<div class="section-header">INFORMATIONS DE L'ÉLÈVE</div>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ student.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ student.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ student.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ student.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ student.address }}</td>
|
||||
<td class="label">Genre</td>
|
||||
<td class="value">{{ student|getStudentGender }}</td>
|
||||
<td class="label">Niveau</td>
|
||||
<td class="value">{{ student|getStudentLevel }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Genre</td>
|
||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ student.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value">{{ student.formatted_birth_date }}</td>
|
||||
<td class="label">Lieu de naissance</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student.birth_place }}{% if student.birth_postal_code %} ({{ student.birth_postal_code }}){% endif %}</td>
|
||||
</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>
|
||||
<td class="label">Nationalité</td>
|
||||
<td class="value">{{ student.nationality }}</td>
|
||||
<td class="label">Médecin traitant</td>
|
||||
<td class="value">{{ student.attending_physician }}</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>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ student.address }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Responsables -->
|
||||
<!-- ═══════ RESPONSABLES ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">RESPONSABLES</div>
|
||||
<div class="section-header">RESPONSABLES LÉGAUX</div>
|
||||
{% for guardian in student.getGuardians %}
|
||||
<div>
|
||||
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
||||
<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-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.address }}</td>
|
||||
<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-cell">Email</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.email }}</td>
|
||||
<td class="label">Email</td>
|
||||
<td class="value" colspan="3">{{ guardian.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
||||
<td class="label-cell">Téléphone</td>
|
||||
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ guardian.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Profession</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.profession }}</td>
|
||||
<td class="label">Profession</td>
|
||||
<td class="value" colspan="3">{{ guardian.profession }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p style="color: #94a3b8; font-style: italic; padding: 8px">
|
||||
Aucun responsable renseigné.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Fratrie -->
|
||||
<!-- ═══════ FRATRIE ═══════ -->
|
||||
{% if student.getSiblings %}
|
||||
<div class="section">
|
||||
<div class="section-title">FRATRIE</div>
|
||||
<div class="section-header">FRATRIE</div>
|
||||
{% for sibling in student.getSiblings %}
|
||||
<div>
|
||||
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<div class="subsection-title">Frère / Sœur {{ forloop.counter }}</div>
|
||||
<table class="data">
|
||||
<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>
|
||||
<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-cell">Né(e) le</td>
|
||||
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value" colspan="3">{{ sibling.birth_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Paiement -->
|
||||
<!-- ═══════ PAIEMENT ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">MODALITÉS DE PAIEMENT</div>
|
||||
<table>
|
||||
<div class="section-header">MODALITÉS DE PAIEMENT</div>
|
||||
<table class="payment">
|
||||
<tr>
|
||||
<td class="label-cell">Frais d'inscription</td>
|
||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
||||
<td class="label">Frais d'inscription</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getRegistrationPaymentMethod }} — {{ student|getRegistrationPaymentPlan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Frais de scolarité</td>
|
||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
||||
<td class="label">Frais de scolarité</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getTuitionPaymentMethod }} — {{ student|getTuitionPaymentPlan }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Signature -->
|
||||
<div class="signature">
|
||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
||||
<!-- ═══════ 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>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<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,7 +3,7 @@ from django.urls import path, re_path
|
||||
from . import views
|
||||
|
||||
# RF
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, generate_registration_pdf
|
||||
# SubClasses
|
||||
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
|
||||
# Files
|
||||
@ -30,6 +30,7 @@ from .views import (
|
||||
)
|
||||
|
||||
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]+)/resend$', resend, name="resend"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),
|
||||
|
||||
@ -8,18 +8,22 @@ from N3wtSchool import renderers
|
||||
from N3wtSchool import bdd
|
||||
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from reportlab.pdfgen import canvas
|
||||
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 import File
|
||||
from pathlib import Path
|
||||
import os
|
||||
from enum import Enum
|
||||
from urllib.parse import unquote_to_bytes
|
||||
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger, PdfReader
|
||||
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||
from PyPDF2.errors import PdfReadError
|
||||
|
||||
import shutil
|
||||
@ -29,9 +33,79 @@ import json
|
||||
from django.http import QueryDict
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from svglib.svglib import svg2rlg
|
||||
|
||||
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):
|
||||
"""
|
||||
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
||||
@ -55,6 +129,42 @@ def save_file_replacing_existing(file_field, filename, content, save=True):
|
||||
# Sauvegarder le nouveau fichier
|
||||
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):
|
||||
"""
|
||||
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]
|
||||
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
|
||||
if m.file and hasattr(m.file, 'name') and 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}")
|
||||
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
|
||||
|
||||
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:
|
||||
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
|
||||
master_file_changed = template_file_name != file_name
|
||||
# --- 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)
|
||||
continue
|
||||
|
||||
# Sinon, création du template comme avant
|
||||
# Sinon, création du template — sauvegarder d'abord pour obtenir un pk
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
@ -262,8 +459,10 @@ def create_templates_for_registration_form(register_form):
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
tmpl.save() # pk attribué ici
|
||||
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):
|
||||
try:
|
||||
import shutil
|
||||
@ -453,6 +652,8 @@ def rfToPDF(registerForm, filename):
|
||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||
'student': registerForm.student,
|
||||
'establishment': registerForm.establishment,
|
||||
'school_year': registerForm.school_year,
|
||||
}
|
||||
|
||||
# Générer le PDF
|
||||
@ -474,6 +675,24 @@ def rfToPDF(registerForm, filename):
|
||||
|
||||
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):
|
||||
"""
|
||||
Supprime le fichier et le dossier associés à un RegistrationForm.
|
||||
@ -527,55 +746,196 @@ def getHistoricalYears(count=5):
|
||||
|
||||
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)
|
||||
et l'associe au RegistrationSchoolFileTemplate.
|
||||
Le PDF contient le titre, les labels et types de champs.
|
||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||
Génère un PDF composite du formulaire dynamique:
|
||||
- le document source uploadé (PDF/image) si présent,
|
||||
- puis un rendu du formulaire (similaire à l'aperçu),
|
||||
- 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(" ", "_")
|
||||
filename = f"{form_name}.pdf"
|
||||
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
|
||||
|
||||
# Générer le PDF
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
# Compatibilité ascendante : charger depuis un chemin si nécessaire
|
||||
if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
|
||||
base_file_ext = os.path.splitext(base_pdf_path)[1].lower()
|
||||
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}")
|
||||
|
||||
writer = PdfWriter()
|
||||
has_source_document = False
|
||||
source_is_image = False
|
||||
source_image_reader = None
|
||||
source_image_size = None
|
||||
|
||||
# 1) Charger le document source (PDF/image) si présent
|
||||
if base_pdf_content:
|
||||
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:
|
||||
if field.get("type") == "signature":
|
||||
value = field.get("value")
|
||||
if isinstance(value, str) and value.startswith("data:image"):
|
||||
signatures.append(value)
|
||||
|
||||
enable_source_signature_overlay = False
|
||||
if signatures and len(writer.pages) > 0 and enable_source_signature_overlay:
|
||||
try:
|
||||
target_page = writer.pages[len(writer.pages) - 1]
|
||||
page_width = float(target_page.mediabox.width)
|
||||
page_height = float(target_page.mediabox.height)
|
||||
|
||||
packet = BytesIO()
|
||||
c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height))
|
||||
|
||||
sig_width = 170
|
||||
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
|
||||
|
||||
# Titre
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||
y -= 40
|
||||
c.setFont("Helvetica-Bold", 18)
|
||||
c.drawString(60, y, form_json.get("title", "Formulaire"))
|
||||
y -= 35
|
||||
|
||||
# Champs
|
||||
c.setFont("Helvetica", 12)
|
||||
fields = form_json.get("fields", [])
|
||||
for field in fields:
|
||||
label = field.get("label", field.get("id", ""))
|
||||
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", "")
|
||||
# Afficher la valeur si elle existe
|
||||
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:
|
||||
|
||||
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()
|
||||
buffer.seek(0)
|
||||
pdf_content = buffer.read()
|
||||
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}")
|
||||
|
||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||
if os.path.exists(existing_file_path):
|
||||
os.remove(existing_file_path)
|
||||
register_form.registration_file.delete(save=False)
|
||||
# 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)
|
||||
|
||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||
out = BytesIO()
|
||||
writer.write(out)
|
||||
out.seek(0)
|
||||
return ContentFile(out.read(), name=os.path.basename(filename))
|
||||
|
||||
@ -5,7 +5,8 @@ from .register_form_views import (
|
||||
resend,
|
||||
archive,
|
||||
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 (
|
||||
RegistrationSchoolFileMasterView,
|
||||
@ -48,6 +49,7 @@ __all__ = [
|
||||
'get_registration_files_by_group',
|
||||
'get_school_file_templates_by_rf',
|
||||
'get_parent_file_templates_by_rf',
|
||||
'generate_registration_pdf',
|
||||
'StudentView',
|
||||
'StudentListView',
|
||||
'ChildrenListView',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.http.response import JsonResponse
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.views import APIView
|
||||
@ -411,6 +412,17 @@ class RegisterFormWithIdView(APIView):
|
||||
# Initialisation de la liste des fichiers à fusionner
|
||||
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
|
||||
if registerForm.registration_file:
|
||||
fileNames.append(registerForm.registration_file.path)
|
||||
@ -946,3 +958,26 @@ def get_parent_file_templates_by_rf(request, id):
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
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)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
}
|
||||
)
|
||||
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)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
|
||||
@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
if 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}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
|
||||
@ -16,6 +16,20 @@ import Subscriptions.util as util
|
||||
|
||||
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):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||
@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
responses = None
|
||||
if "responses" in formTemplateData:
|
||||
resp = formTemplateData["responses"]
|
||||
if isinstance(resp, dict) and "responses" in resp:
|
||||
responses = resp["responses"]
|
||||
elif isinstance(resp, dict):
|
||||
responses = resp
|
||||
responses = _extract_nested_responses(resp)
|
||||
|
||||
# Nettoyer les meta-cles qui ne sont pas des reponses de champs
|
||||
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:
|
||||
for field in formTemplateData["fields"]:
|
||||
field_id = field.get("id")
|
||||
if field_id and field_id in responses:
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# Cas 2 : Formulaire dynamique (JSON)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Régénérer le PDF si besoin
|
||||
@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
and formTemplateData.get("fields")
|
||||
and hasattr(template, "file")
|
||||
):
|
||||
old_pdf_name = None
|
||||
if template.file and template.file.name:
|
||||
old_pdf_name = os.path.basename(template.file.name)
|
||||
# Lire le contenu du fichier source en mémoire AVANT suppression.
|
||||
# Priorité au fichier master (document source admin) pour éviter
|
||||
# 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:
|
||||
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)
|
||||
if os.path.exists(template.file.path):
|
||||
os.remove(template.file.path)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
||||
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
||||
template.file.save(pdf_filename, pdf_file, save=True)
|
||||
pdf_file = generate_form_json_pdf(
|
||||
template.registration_form,
|
||||
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(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@ -11,13 +11,10 @@ def run_command(command):
|
||||
print(f"stderr: {stderr.decode()}")
|
||||
return process.returncode
|
||||
|
||||
test_mode = os.getenv('test_mode', '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=True
|
||||
test_mode = os.getenv('TEST_MODE', 'false').lower() == 'true'
|
||||
flush_data = os.getenv('FLUSH_DATA', 'false').lower() == 'true'
|
||||
migrate_data = os.getenv('MIGRATE_DATA', 'false').lower() == 'true'
|
||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||
watch_mode=True
|
||||
|
||||
collect_static_cmd = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"]
|
||||
@ -64,12 +61,6 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
@ -78,6 +69,11 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
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",
|
||||
"settings": "Settings",
|
||||
"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",
|
||||
"settings": "Paramètres",
|
||||
"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,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
MessageCircleHeart,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Popup from '@/components/Popup';
|
||||
@ -24,6 +25,7 @@ import {
|
||||
FE_ADMIN_PLANNING_URL,
|
||||
FE_ADMIN_SETTINGS_URL,
|
||||
FE_ADMIN_MESSAGERIE_URL,
|
||||
FE_ADMIN_FEEDBACK_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
@ -82,6 +84,12 @@ export default function Layout({ children }) {
|
||||
url: FE_ADMIN_MESSAGERIE_URL,
|
||||
icon: MessageSquare,
|
||||
},
|
||||
feedback: {
|
||||
id: 'feedback',
|
||||
name: t('feedback'),
|
||||
url: FE_ADMIN_FEEDBACK_URL,
|
||||
icon: MessageCircleHeart,
|
||||
},
|
||||
settings: {
|
||||
id: 'settings',
|
||||
name: t('settings'),
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import EmailSender from '@/components/Admin/EmailSender';
|
||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function MessageriePage({ csrfToken }) {
|
||||
const tabs = [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Envoyer un Mail',
|
||||
content: <EmailSender csrfToken={csrfToken} />,
|
||||
},
|
||||
{
|
||||
id: 'instant',
|
||||
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]);
|
||||
|
||||
// Load specialities for evaluations
|
||||
// Load specialities for evaluations (filtered by current school year)
|
||||
useEffect(() => {
|
||||
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))
|
||||
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Table from '@/components/Table';
|
||||
import {
|
||||
Edit3,
|
||||
Users,
|
||||
@ -9,6 +8,12 @@ import {
|
||||
Eye,
|
||||
Upload,
|
||||
CalendarDays,
|
||||
Award,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
@ -16,7 +21,13 @@ import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
|
||||
import {
|
||||
fetchChildren,
|
||||
editRegisterForm,
|
||||
fetchStudentCompetencies,
|
||||
fetchAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
fetchEvaluations,
|
||||
fetchStudentEvaluations,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
||||
import logger from '@/utils/logger';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
@ -27,13 +38,47 @@ import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import ParentPlanningSection from '@/components/ParentPlanningSection';
|
||||
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() {
|
||||
const [children, setChildren] = useState([]);
|
||||
const { user, selectedEstablishmentId } = useEstablishment();
|
||||
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload
|
||||
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé
|
||||
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant
|
||||
const { user, selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
|
||||
const [uploadingStudentId, setUploadingStudentId] = useState(null);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [uploadState, setUploadState] = useState('off');
|
||||
const [showPlanning, setShowPlanning] = useState(false);
|
||||
const [planningClassName, setPlanningClassName] = useState(null);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState([]);
|
||||
@ -42,16 +87,114 @@ export default function ParentHomePage() {
|
||||
const [reloadFetch, setReloadFetch] = useState(false);
|
||||
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(() => {
|
||||
if (user !== null) {
|
||||
const userIdFromSession = user.user_id;
|
||||
fetchChildren(userIdFromSession, selectedEstablishmentId).then((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);
|
||||
}
|
||||
}, [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(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
// Fetch des événements à venir
|
||||
@ -132,153 +275,6 @@ export default function ParentHomePage() {
|
||||
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 (
|
||||
<div className="w-full h-full">
|
||||
{showPlanning && planningClassName ? (
|
||||
@ -326,12 +322,163 @@ export default function ParentHomePage() {
|
||||
title="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>
|
||||
{/* Composant FileUpload et bouton Valider en dessous du tableau */}
|
||||
{uploadState === 'on' && uploadingStudentId && (
|
||||
<div className="mt-4">
|
||||
)}
|
||||
</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}
|
||||
@ -339,7 +486,7 @@ export default function ParentHomePage() {
|
||||
<button
|
||||
className={`mt-4 px-6 py-2 rounded-md ${
|
||||
uploadedFile
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
? 'bg-primary text-white hover:bg-secondary'
|
||||
: 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
@ -349,6 +496,201 @@ export default function ParentHomePage() {
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
||||
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
||||
BE_GESTIONEMAIL_SEND_FEEDBACK_URL,
|
||||
} from '@/utils/Url';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
import { getCsrfToken } from '@/utils/getCsrfToken';
|
||||
@ -19,3 +20,13 @@ export const sendEmail = async (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);
|
||||
};
|
||||
|
||||
export const fetchRegistrationSchoolFileMasterById = (id) => {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
|
||||
};
|
||||
|
||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
return fetchWithAuth(url);
|
||||
|
||||
@ -37,10 +37,10 @@ export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchSpecialities = (establishment) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
export const fetchSpecialities = (establishment, schoolYear = null) => {
|
||||
let url = `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`;
|
||||
if (schoolYear) url += `&school_year=${schoolYear}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchTeachers = (establishment) => {
|
||||
|
||||
@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import SelectChoice from './SelectChoice';
|
||||
import Button from './Button';
|
||||
import FileUpload from './FileUpload';
|
||||
import IconSelector from './IconSelector';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
@ -14,6 +15,8 @@ export default function AddFieldModal({
|
||||
onSubmit,
|
||||
editingField = null,
|
||||
editingIndex = -1,
|
||||
hasMasterFile = false,
|
||||
onMasterFileUpload,
|
||||
}) {
|
||||
const isEditing = editingIndex >= 0;
|
||||
|
||||
@ -29,6 +32,7 @@ export default function AddFieldModal({
|
||||
acceptTypes: '',
|
||||
maxSize: 5, // 5MB par défaut
|
||||
checked: false,
|
||||
masterFileToUpload: null,
|
||||
validation: {
|
||||
pattern: '',
|
||||
minLength: '',
|
||||
@ -56,6 +60,7 @@ export default function AddFieldModal({
|
||||
acceptTypes: '',
|
||||
maxSize: 5,
|
||||
checked: false,
|
||||
masterFileToUpload: null,
|
||||
signatureData: '',
|
||||
backgroundColor: '#ffffff',
|
||||
penColor: '#000000',
|
||||
@ -492,6 +497,31 @@ export default function AddFieldModal({
|
||||
|
||||
{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
|
||||
name="acceptTypes"
|
||||
control={control}
|
||||
|
||||
@ -2,6 +2,7 @@ import logger from '@/utils/logger';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import SelectChoice from './SelectChoice';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import Button from './Button';
|
||||
@ -33,7 +34,22 @@ export default function FormRenderer({
|
||||
onFormSubmit = (data) => {
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
}, // 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 {
|
||||
handleSubmit,
|
||||
control,
|
||||
@ -57,8 +73,7 @@ export default function FormRenderer({
|
||||
const hasFiles = Object.keys(data).some((key) => {
|
||||
return (
|
||||
data[key] instanceof FileList ||
|
||||
(data[key] && data[key][0] instanceof File) ||
|
||||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
|
||||
(data[key] && data[key][0] instanceof File)
|
||||
);
|
||||
});
|
||||
|
||||
@ -83,29 +98,6 @@ export default function FormRenderer({
|
||||
formData.append(`files.${key}`, value[i]);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
typeof value === 'string' &&
|
||||
value.startsWith('data:image')
|
||||
) {
|
||||
// Gérer les signatures (SVG ou images base64)
|
||||
if (value.includes('svg+xml')) {
|
||||
// Gérer les signatures SVG
|
||||
const svgData = value.split(',')[1];
|
||||
const svgBlob = new Blob([atob(svgData)], {
|
||||
type: 'image/svg+xml',
|
||||
});
|
||||
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
|
||||
} else {
|
||||
// Gérer les images base64 classiques
|
||||
const byteString = atob(value.split(',')[1]);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: 'image/png' });
|
||||
formData.append(`files.${key}`, blob, `signature_${key}.png`);
|
||||
}
|
||||
} else {
|
||||
// Gérer les autres types de champs
|
||||
formData.append(
|
||||
@ -356,12 +348,26 @@ export default function FormRenderer({
|
||||
control={control}
|
||||
rules={{ required: field.required }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
masterFileUrl ? (
|
||||
<div className="w-full bg-neutral border border-gray-200 rounded p-3">
|
||||
{field.label && (
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">
|
||||
{field.label}
|
||||
</p>
|
||||
)}
|
||||
<iframe
|
||||
src={masterFileUrl}
|
||||
title={field.label || 'Document'}
|
||||
className="w-full rounded border border-gray-200 bg-white"
|
||||
style={{ height: '520px', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
selectionMessage={field.label}
|
||||
required={field.required}
|
||||
uploadedFileName={value ? value[0]?.name : null}
|
||||
onFileSelect={(file) => {
|
||||
// Créer un objet de type FileList similaire pour la compatibilité
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
onChange(dataTransfer.files);
|
||||
@ -374,6 +380,7 @@ export default function FormRenderer({
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@ -406,7 +413,14 @@ export default function FormRenderer({
|
||||
control={control}
|
||||
rules={{ required: field.required }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
masterFile
|
||||
? 'mt-3 flex justify-end'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<div className={masterFile ? 'w-full max-w-xs' : 'w-full'}>
|
||||
<SignatureField
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
@ -415,6 +429,8 @@ export default function FormRenderer({
|
||||
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||
penColor={field.penColor || '#000000'}
|
||||
penWidth={field.penWidth || 2}
|
||||
displayWidth={masterFile ? 260 : 400}
|
||||
displayHeight={masterFile ? 120 : 200}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
@ -424,6 +440,7 @@ export default function FormRenderer({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -177,6 +177,8 @@ export default function FormTemplateBuilder({
|
||||
initialData,
|
||||
groups,
|
||||
isEditing,
|
||||
masterFile = null,
|
||||
onMasterFileUpload,
|
||||
}) {
|
||||
const [formConfig, setFormConfig] = useState({
|
||||
id: initialData?.id || 0,
|
||||
@ -186,7 +188,9 @@ export default function FormTemplateBuilder({
|
||||
});
|
||||
|
||||
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);
|
||||
@ -209,7 +213,11 @@ export default function FormTemplateBuilder({
|
||||
submitLabel: 'Envoyer',
|
||||
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]);
|
||||
|
||||
@ -256,6 +264,21 @@ export default function FormTemplateBuilder({
|
||||
const handleFieldSubmit = (data, currentField, editIndex) => {
|
||||
const isHeadingType = data.type.startsWith('heading');
|
||||
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 = {
|
||||
...data,
|
||||
@ -653,7 +676,7 @@ export default function FormTemplateBuilder({
|
||||
<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">
|
||||
{formConfig.fields.length > 0 ? (
|
||||
<FormRenderer formConfig={formConfig} />
|
||||
<FormRenderer formConfig={formConfig} masterFile={masterFile} />
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-center">
|
||||
Ajoutez des champs pour voir l'aperçu
|
||||
@ -668,6 +691,8 @@ export default function FormTemplateBuilder({
|
||||
isOpen={showAddFieldModal}
|
||||
onClose={() => setShowAddFieldModal(false)}
|
||||
onSubmit={handleFieldSubmit}
|
||||
hasMasterFile={Boolean(masterFile)}
|
||||
onMasterFileUpload={onMasterFileUpload}
|
||||
editingField={
|
||||
editingIndex >= 0
|
||||
? formConfig.fields[editingIndex]
|
||||
|
||||
@ -11,6 +11,8 @@ const SignatureField = ({
|
||||
backgroundColor = '#ffffff',
|
||||
penColor = '#000000',
|
||||
penWidth = 2,
|
||||
displayWidth = 400,
|
||||
displayHeight = 200,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
@ -29,9 +31,6 @@ const SignatureField = ({
|
||||
|
||||
// Support High DPI / Retina displays
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const displayWidth = 400;
|
||||
const displayHeight = 200;
|
||||
|
||||
// Ajuster la taille physique du canvas pour la haute résolution
|
||||
canvas.width = displayWidth * devicePixelRatio;
|
||||
canvas.height = displayHeight * devicePixelRatio;
|
||||
@ -56,7 +55,7 @@ const SignatureField = ({
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
}, [backgroundColor, penColor, penWidth]);
|
||||
}, [backgroundColor, penColor, penWidth, displayWidth, displayHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeCanvas();
|
||||
@ -226,11 +225,12 @@ const SignatureField = ({
|
||||
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) {
|
||||
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
||||
const svgData = generateSVG(newPaths);
|
||||
onChange(svgData);
|
||||
const canvas = canvasRef.current;
|
||||
const pngData = canvas ? canvas.toDataURL('image/png') : '';
|
||||
onChange(pngData);
|
||||
}
|
||||
},
|
||||
[isDrawing, onChange, svgPaths, currentPath]
|
||||
@ -238,7 +238,7 @@ const SignatureField = ({
|
||||
|
||||
// Générer le SVG à partir des paths
|
||||
const generateSVG = (paths) => {
|
||||
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
const svgContent = `<svg width="${displayWidth}" height="${displayHeight}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||
${paths
|
||||
.map(
|
||||
@ -257,9 +257,6 @@ const SignatureField = ({
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// Effacer en tenant compte des dimensions d'affichage
|
||||
const displayWidth = 400;
|
||||
const displayHeight = 200;
|
||||
|
||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
@ -273,6 +270,14 @@ const SignatureField = ({
|
||||
}
|
||||
};
|
||||
|
||||
const hintText = readOnly
|
||||
? isEmpty
|
||||
? 'Aucune signature'
|
||||
: 'Signature'
|
||||
: isEmpty
|
||||
? 'Signez dans la zone ci-dessus'
|
||||
: 'Signature capturée';
|
||||
|
||||
return (
|
||||
<div className="signature-field">
|
||||
{label && (
|
||||
@ -282,7 +287,7 @@ const SignatureField = ({
|
||||
</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
|
||||
ref={canvasRef}
|
||||
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||
@ -307,16 +312,8 @@ const SignatureField = ({
|
||||
onTouchEnd={readOnly ? undefined : stopDrawing}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
{readOnly
|
||||
? isEmpty
|
||||
? 'Aucune signature'
|
||||
: 'Signature'
|
||||
: isEmpty
|
||||
? 'Signez dans la zone ci-dessus'
|
||||
: 'Signature capturée'}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<div className="text-xs text-gray-500">{hintText}</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
|
||||
@ -34,6 +34,44 @@ export default function DynamicFormsList({
|
||||
const [formsValidation, setFormsValidation] = useState({});
|
||||
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
|
||||
useEffect(() => {
|
||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
||||
@ -90,11 +128,7 @@ export default function DynamicFormsList({
|
||||
useEffect(() => {
|
||||
// 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(
|
||||
(tpl) =>
|
||||
tpl.isValidated === true ||
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] &&
|
||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
(tpl) => tpl.isValidated === true || hasLocalCompletion(tpl.id)
|
||||
);
|
||||
|
||||
onValidationChange(allFormsValid);
|
||||
@ -113,10 +147,12 @@ export default function DynamicFormsList({
|
||||
try {
|
||||
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||
|
||||
const normalizedResponses = extractResponses(formData);
|
||||
|
||||
// Sauvegarder les données du formulaire
|
||||
setFormsData((prev) => ({
|
||||
...prev,
|
||||
[templateId]: formData,
|
||||
[templateId]: normalizedResponses,
|
||||
}));
|
||||
|
||||
// Marquer le formulaire comme complété
|
||||
@ -145,13 +181,7 @@ export default function DynamicFormsList({
|
||||
* Vérifie si un formulaire est complété
|
||||
*/
|
||||
const isFormCompleted = (templateId) => {
|
||||
return (
|
||||
formsValidation[templateId] === true ||
|
||||
(formsData[templateId] &&
|
||||
Object.keys(formsData[templateId]).length > 0) ||
|
||||
(existingResponses[templateId] &&
|
||||
Object.keys(existingResponses[templateId]).length > 0)
|
||||
);
|
||||
return hasLocalCompletion(templateId);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -229,13 +259,7 @@ export default function DynamicFormsList({
|
||||
{
|
||||
schoolFileTemplates.filter((tpl) => {
|
||||
// Validé ou complété localement
|
||||
return (
|
||||
tpl.isValidated === true ||
|
||||
(formsData[tpl.id] &&
|
||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] &&
|
||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
return tpl.isValidated === true || hasLocalCompletion(tpl.id);
|
||||
}).length
|
||||
}
|
||||
{' / '}
|
||||
@ -247,14 +271,10 @@ export default function DynamicFormsList({
|
||||
// Helper pour état
|
||||
const getState = (tpl) => {
|
||||
if (tpl.isValidated === true) return 0; // validé
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] &&
|
||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] &&
|
||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
if (isCompletedLocally) return 1; // complété/en attente
|
||||
return 2; // à compléter/refusé
|
||||
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||
if (isCompletedLocally) return 1; // complété (en attente de traitement)
|
||||
if (tpl.isValidated === false) return 2; // refusé
|
||||
return 3; // à compléter
|
||||
};
|
||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
||||
return getState(a) - getState(b);
|
||||
@ -268,12 +288,7 @@ export default function DynamicFormsList({
|
||||
typeof tpl.isValidated === 'boolean'
|
||||
? tpl.isValidated
|
||||
: undefined;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] &&
|
||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] &&
|
||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||
|
||||
// Statut d'affichage
|
||||
let statusLabel = '';
|
||||
@ -300,22 +315,9 @@ export default function DynamicFormsList({
|
||||
: textClass;
|
||||
canEdit = false;
|
||||
} else if (isValidated === false) {
|
||||
if (isCompletedLocally) {
|
||||
statusLabel = 'Complété';
|
||||
statusColor = 'orange';
|
||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||
borderClass = isActive
|
||||
? 'border border-orange-300'
|
||||
: 'border border-orange-200';
|
||||
textClass = isActive
|
||||
? 'text-orange-900 font-semibold'
|
||||
: 'text-orange-700';
|
||||
canEdit = true;
|
||||
} else {
|
||||
statusLabel = 'Refusé';
|
||||
statusColor = 'red';
|
||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
||||
icon = <Hourglass className="w-5 h-5 text-red-500" />;
|
||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||
borderClass = isActive
|
||||
? 'border border-red-300'
|
||||
@ -324,7 +326,6 @@ export default function DynamicFormsList({
|
||||
? 'text-red-900 font-semibold'
|
||||
: 'text-red-700';
|
||||
canEdit = true;
|
||||
}
|
||||
} else {
|
||||
if (isCompletedLocally) {
|
||||
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">
|
||||
Validé
|
||||
</span>
|
||||
) : (formsData[currentTemplate.id] &&
|
||||
Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
||||
(existingResponses[currentTemplate.id] &&
|
||||
Object.keys(existingResponses[currentTemplate.id]).length >
|
||||
0) ? (
|
||||
) : currentTemplate.isValidated === false ? (
|
||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||
Refusé
|
||||
</span>
|
||||
) : hasLocalCompletion(currentTemplate.id) ? (
|
||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
||||
Complété
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||
Refusé
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-sm font-semibold">
|
||||
En attente
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -430,14 +431,10 @@ export default function DynamicFormsList({
|
||||
// Trouver l'index du template courant dans la liste triée
|
||||
const getState = (tpl) => {
|
||||
if (tpl.isValidated === true) return 0;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] &&
|
||||
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] &&
|
||||
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
const isCompletedLocally = hasLocalCompletion(tpl.id);
|
||||
if (isCompletedLocally) return 1;
|
||||
return 2;
|
||||
if (tpl.isValidated === false) return 2;
|
||||
return 3;
|
||||
};
|
||||
const sortedTemplates = [...schoolFileTemplates].sort(
|
||||
(a, b) => getState(a) - getState(b)
|
||||
@ -469,8 +466,11 @@ export default function DynamicFormsList({
|
||||
submitLabel:
|
||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
}}
|
||||
masterFile={
|
||||
currentTemplate.master_file_url || currentTemplate.file || null
|
||||
}
|
||||
initialValues={
|
||||
formsData[currentTemplate.id] ||
|
||||
extractResponses(formsData[currentTemplate.id]) ||
|
||||
existingResponses[currentTemplate.id] ||
|
||||
{}
|
||||
}
|
||||
|
||||
@ -53,10 +53,10 @@ const FilesModal = ({
|
||||
.then((parentFiles) => {
|
||||
// Construct the categorized files list
|
||||
const categorizedFiles = {
|
||||
registrationFile: selectedRegisterForm.registration_file
|
||||
registrationFile: selectedRegisterForm.student?.id
|
||||
? {
|
||||
name: 'Fiche élève',
|
||||
url: getSecureFileUrl(selectedRegisterForm.registration_file),
|
||||
url: `/api/generate-pdf?studentId=${selectedRegisterForm.student.id}`,
|
||||
}
|
||||
: null,
|
||||
fusionFile: selectedRegisterForm.fusion_file
|
||||
|
||||
@ -285,6 +285,29 @@ export default function InscriptionFormShared({
|
||||
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
|
||||
const formTemplateData = {
|
||||
id: currentTemplate.id,
|
||||
@ -300,17 +323,17 @@ export default function InscriptionFormShared({
|
||||
).map((field) => ({
|
||||
...field,
|
||||
...(field.type === 'checkbox'
|
||||
? { checked: formData[field.id] || false }
|
||||
? { checked: normalizedResponses[field.id] || false }
|
||||
: {}),
|
||||
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
||||
...(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email'
|
||||
? { value: formData[field.id] || '' }
|
||||
...(field.type === 'radio'
|
||||
? { selected: normalizedResponses[field.id] }
|
||||
: {}),
|
||||
...(field.id
|
||||
? { value: normalizedResponses[field.id] ?? field.value ?? '' }
|
||||
: {}),
|
||||
})),
|
||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
responses: formData,
|
||||
responses: normalizedResponses,
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
||||
let newResponses = formData;
|
||||
let newResponses = normalizedResponses;
|
||||
if (
|
||||
result &&
|
||||
result.data &&
|
||||
|
||||
@ -152,7 +152,11 @@ export default function ValidateSubscription({
|
||||
};
|
||||
|
||||
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) => ({
|
||||
name: template.name || 'Document scolaire',
|
||||
file: template.file,
|
||||
@ -213,7 +217,11 @@ export default function ValidateSubscription({
|
||||
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
||||
</h3>
|
||||
<iframe
|
||||
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
|
||||
src={
|
||||
allTemplates[currentTemplateIndex].type === 'main'
|
||||
? allTemplates[currentTemplateIndex].file
|
||||
: getSecureFileUrl(allTemplates[currentTemplateIndex].file)
|
||||
}
|
||||
title={
|
||||
allTemplates[currentTemplateIndex].type === 'main'
|
||||
? 'Document Principal'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
import {
|
||||
// GET
|
||||
fetchRegistrationFileGroups,
|
||||
@ -32,6 +32,7 @@ import CheckBox from '@/components/Form/CheckBox';
|
||||
import Button from '@/components/Form/Button';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import { FE_ADMIN_STRUCTURE_FORM_BUILDER_URL } from '@/utils/Url';
|
||||
|
||||
function getItemBgColor(type, selected, forceTheme = false) {
|
||||
// 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 [groups, setGroups] = useState([]);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [fileToEdit, setFileToEdit] = useState(null);
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
@ -226,10 +227,8 @@ export default function FilesGroupsManagement({
|
||||
const handleDocDropdownSelect = (type) => {
|
||||
setIsDocDropdownOpen(false);
|
||||
if (type === 'formulaire') {
|
||||
// Ouvre la modale unique en mode création
|
||||
setIsEditing(false);
|
||||
setFileToEdit(null);
|
||||
setIsModalOpen(true);
|
||||
const groupParam = selectedGroupId ? `?groupId=${selectedGroupId}` : '';
|
||||
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}${groupParam}`);
|
||||
} else if (type === 'formulaire_existant') {
|
||||
setIsFileUploadPopupOpen(true);
|
||||
setFileToEdit({});
|
||||
@ -329,28 +328,29 @@ export default function FilesGroupsManagement({
|
||||
};
|
||||
|
||||
const editTemplateMaster = (file) => {
|
||||
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
|
||||
if (
|
||||
!file.formMasterData ||
|
||||
!Array.isArray(file.formMasterData.fields) ||
|
||||
file.formMasterData.fields.length === 0
|
||||
) {
|
||||
setFileToEdit(file);
|
||||
setIsFileUploadPopupOpen(true);
|
||||
setIsEditing(true);
|
||||
const isDynamic =
|
||||
file.formMasterData &&
|
||||
Array.isArray(file.formMasterData.fields) &&
|
||||
file.formMasterData.fields.length > 0;
|
||||
|
||||
if (isDynamic) {
|
||||
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}?id=${file.id}`);
|
||||
} else {
|
||||
setIsEditing(true);
|
||||
setFileToEdit(file);
|
||||
setIsModalOpen(true);
|
||||
setIsEditing(true);
|
||||
setIsFileUploadPopupOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSchoolFileMaster = ({
|
||||
const handleCreateSchoolFileMaster = (
|
||||
{
|
||||
name,
|
||||
group_ids,
|
||||
formMasterData,
|
||||
file,
|
||||
}) => {
|
||||
},
|
||||
onCreated
|
||||
) => {
|
||||
// Toujours envoyer en FormData, même sans fichier
|
||||
const dataToSend = new FormData();
|
||||
const jsonData = {
|
||||
@ -379,12 +379,12 @@ export default function FilesGroupsManagement({
|
||||
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
|
||||
.then((data) => {
|
||||
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
|
||||
setIsModalOpen(false);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été créé avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
if (onCreated) onCreated(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error creating form:', error);
|
||||
@ -460,7 +460,6 @@ export default function FilesGroupsManagement({
|
||||
setSchoolFileMasters((prevFichiers) =>
|
||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||
);
|
||||
setIsModalOpen(false);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||
'success',
|
||||
@ -495,7 +494,6 @@ export default function FilesGroupsManagement({
|
||||
setSchoolFileMasters((prevFichiers) =>
|
||||
prevFichiers.map((f) => (f.id === id ? data : f))
|
||||
);
|
||||
setIsModalOpen(false);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||
'success',
|
||||
@ -888,13 +886,6 @@ export default function FilesGroupsManagement({
|
||||
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 (
|
||||
<div className="w-full">
|
||||
{/* Aide optionnelle */}
|
||||
@ -1094,37 +1085,6 @@ export default function FilesGroupsManagement({
|
||||
</div>
|
||||
</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 */}
|
||||
<Modal
|
||||
isOpen={isFileUploadPopupOpen}
|
||||
@ -1262,11 +1222,13 @@ export default function FilesGroupsManagement({
|
||||
!fileToEdit?.file
|
||||
)
|
||||
return;
|
||||
handleCreateSchoolFileMaster({
|
||||
handleCreateSchoolFileMaster(
|
||||
{
|
||||
name: fileToEdit.name,
|
||||
group_ids: fileToEdit.groups,
|
||||
file: fileToEdit.file,
|
||||
});
|
||||
}
|
||||
);
|
||||
setIsFileUploadPopupOpen(false);
|
||||
setFileToEdit(null);
|
||||
}}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
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, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.token}`,
|
||||
Connection: 'close',
|
||||
},
|
||||
});
|
||||
|
||||
@ -48,7 +48,8 @@ export default async function handler(req, res) {
|
||||
|
||||
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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_SEND_FEEDBACK_URL = `${BASE_URL}/GestionEmail/send-feedback/`;
|
||||
|
||||
// GESTION MESSAGERIE
|
||||
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_SCHOOLCLASS_MANAGEMENT_URL =
|
||||
'/admin/structure/SchoolClassManagement';
|
||||
export const FE_ADMIN_STRUCTURE_FORM_BUILDER_URL =
|
||||
'/admin/structure/FormBuilder';
|
||||
|
||||
//ADMIN/DIRECTORY URL
|
||||
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';
|
||||
@ -123,6 +126,9 @@ export const FE_ADMIN_SETTINGS_URL = '/admin/settings';
|
||||
//ADMIN/MESSAGERIE URL
|
||||
export const FE_ADMIN_MESSAGERIE_URL = '/admin/messagerie';
|
||||
|
||||
//ADMIN/FEEDBACK URL
|
||||
export const FE_ADMIN_FEEDBACK_URL = '/admin/feedback';
|
||||
|
||||
// PARENT HOME
|
||||
export const FE_PARENTS_HOME_URL = '/parents';
|
||||
export const FE_PARENTS_MESSAGERIE_URL = '/parents/messagerie';
|
||||
|
||||
@ -10,6 +10,12 @@
|
||||
*/
|
||||
export const getSecureFileUrl = (filePath) => {
|
||||
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/...
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
|
||||
Reference in New Issue
Block a user