5 Commits

51 changed files with 2384 additions and 773 deletions

View File

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

View File

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

View File

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

View File

@ -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/'):]

View File

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

View File

@ -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'),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')},
},
),
]

View File

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

View File

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

View File

@ -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>
<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>
{% 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 &Eacute;l&egrave;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>
</div>
<!-- ═══════ SIGNATURE ═══════ -->
<div class="signature-block">
<p>
Document généré le
<span class="signature-date">{{ signatureDate }}</span> à
<span class="signature-date">{{ signatureTime }}</span>
</p>
</div>
<hr class="footer-line" />
<p class="footer-text">
Ce document est généré automatiquement et fait office de fiche
d'inscription.
</p>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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."
}

View File

@ -7,5 +7,6 @@
"educational_monitoring": "Educational Monitoring",
"settings": "Settings",
"schoolAdmin": "School Administration",
"messagerie": "Messenger"
"messagerie": "Messenger",
"feedback": "Feedback"
}

View 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."
}

View File

@ -7,5 +7,6 @@
"educational_monitoring": "Suivi pédagogique",
"settings": "Paramètres",
"schoolAdmin": "Administration Scolaire",
"messagerie": "Messagerie"
"messagerie": "Messagerie",
"feedback": "Feedback"
}

View 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>
);
}

View File

@ -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'),

View File

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

View 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>
);
}

View File

@ -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));
}

View File

@ -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&apos;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>

View File

@ -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),
});
};

View File

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

View File

@ -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) => {

View File

@ -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&apos;ajouter ce type de champ.
</p>
)}
</div>
<Controller
name="acceptTypes"
control={control}

View File

@ -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>
)}
/>
)}

View File

@ -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&apos;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]

View File

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

View File

@ -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] ||
{}
}

View File

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

View 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 &&

View File

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

View File

@ -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);
}}

View File

@ -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' });
}
}

View 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' });
}
}

View File

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

View File

@ -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://')) {