mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-06 05:01:25 +00:00
fix: signature électronique
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
@ -124,7 +124,6 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=100)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('color_code', models.CharField(default='#FFFFFF', max_length=7)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
@ -154,7 +153,6 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('profile_role', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teacher_profile', to='Auth.profilerole')),
|
||||
('specialities', models.ManyToManyField(blank=True, to='School.speciality')),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
@ -54,6 +54,8 @@ class Migration(migrations.Migration):
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('isValidated', models.BooleanField(blank=True, default=None, null=True)),
|
||||
('electronic_signature', models.TextField(blank=True, help_text='Signature électronique encodée en base64', null=True)),
|
||||
('electronic_signature_date', models.DateTimeField(blank=True, help_text='Date de la signature électronique', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -169,6 +171,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('requires_electronic_signature', models.BooleanField(default=False, help_text='Si activé, le parent devra signer électroniquement ce document')),
|
||||
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('file', models.FileField(blank=True, help_text='Fichier du formulaire existant (PDF, DOC, etc.)', null=True, upload_to=Subscriptions.models.registration_school_file_master_upload_to)),
|
||||
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_masters', to='Establishment.establishment')),
|
||||
|
||||
@ -384,6 +384,7 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
||||
name = models.CharField(max_length=255, default="")
|
||||
is_required = models.BooleanField(default=False)
|
||||
requires_electronic_signature = models.BooleanField(default=False, help_text="Si activé, le parent devra signer électroniquement ce document")
|
||||
formMasterData = models.JSONField(default=list, blank=True, null=True)
|
||||
file = models.FileField(
|
||||
upload_to=registration_school_file_master_upload_to,
|
||||
@ -558,6 +559,9 @@ class RegistrationSchoolFileTemplate(models.Model):
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||
# Signature électronique (base64 SVG ou PNG)
|
||||
electronic_signature = models.TextField(null=True, blank=True, help_text="Signature électronique encodée en base64")
|
||||
electronic_signature_date = models.DateTimeField(null=True, blank=True, help_text="Date de la signature électronique")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -57,6 +57,8 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
master_file_url = serializers.SerializerMethodField()
|
||||
requires_electronic_signature = serializers.SerializerMethodField()
|
||||
is_electronically_signed = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileTemplate
|
||||
@ -72,6 +74,27 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
return obj.master.file.url
|
||||
return None
|
||||
|
||||
def get_requires_electronic_signature(self, obj):
|
||||
# Retourne si le document nécessite une signature électronique
|
||||
if obj.master:
|
||||
return obj.master.requires_electronic_signature
|
||||
return False
|
||||
|
||||
def get_is_electronically_signed(self, obj):
|
||||
# Retourne True si le document a été signé électroniquement
|
||||
return bool(obj.electronic_signature)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Auto-remplir la date de signature si electronic_signature est fournie
|
||||
from django.utils import timezone
|
||||
if 'electronic_signature' in validated_data and validated_data['electronic_signature']:
|
||||
# Nouvelle signature ou re-signature : enregistrer la date
|
||||
validated_data['electronic_signature_date'] = timezone.now()
|
||||
# Si le document était refusé, le repasser en attente de validation
|
||||
if instance.isValidated == False:
|
||||
validated_data['isValidated'] = None
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
|
||||
245
Back-End/Subscriptions/templates/pdfs/dynamic_form.html
Normal file
245
Back-End/Subscriptions/templates/pdfs/dynamic_form.html
Normal file
@ -0,0 +1,245 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{ pdf_title }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1.4cm 1.6cm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.35;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
border-left: 1px dashed #cbd5e1;
|
||||
border-right: 1px dashed #cbd5e1;
|
||||
padding: 0 22px 8px 22px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 0 0 18px 0;
|
||||
}
|
||||
|
||||
.heading1 {
|
||||
font-size: 26pt;
|
||||
font-weight: bold;
|
||||
margin: 18px 0 10px 0;
|
||||
}
|
||||
|
||||
.heading2 {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
margin: 14px 0 8px 0;
|
||||
}
|
||||
|
||||
.heading3 {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
margin: 12px 0 6px 0;
|
||||
}
|
||||
|
||||
.heading4,
|
||||
.heading5,
|
||||
.heading6 {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
margin: 10px 0 6px 0;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 12px 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin: 10px 0 14px 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
padding: 8px 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.phone-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.phone-prefix {
|
||||
width: 78px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.phone-value {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 8px 10px;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-page {
|
||||
margin: 8px 0;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-page img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 8.5pt;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-file {
|
||||
border: 2px dashed #94a3b8;
|
||||
background: #ffffff;
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.signature-box {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
height: 84px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signature-box img {
|
||||
max-width: 200px;
|
||||
max-height: 70px;
|
||||
margin: 6px auto;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<h1 class="title">{{ pdf_title }}</h1>
|
||||
|
||||
{% for field in fields %}
|
||||
{% if field.kind == 'heading' %}
|
||||
{% if field.level == 'heading1' %}
|
||||
<div class="heading1">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading2' %}
|
||||
<div class="heading2">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading3' %}
|
||||
<div class="heading3">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading4' %}
|
||||
<div class="heading4">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading5' %}
|
||||
<div class="heading5">{{ field.text }}</div>
|
||||
{% else %}
|
||||
<div class="heading6">{{ field.text }}</div>
|
||||
{% endif %}
|
||||
{% elif field.kind == 'paragraph' %}
|
||||
<p class="paragraph">{{ field.text }}</p>
|
||||
{% elif field.kind == 'file' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="file-card">
|
||||
{% if field.has_preview %}
|
||||
{% for preview in field.preview_pages %}
|
||||
<div class="preview-page">
|
||||
<img src="{{ preview.src }}" alt="{{ preview.alt }}" width="{{ preview.width }}" height="{{ preview.height }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if field.total_pages > 1 %}
|
||||
<div class="preview-meta">Aperçu de la première page sur {{ field.total_pages }} pages</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-file">Aucun document source fourni</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.kind == 'phone' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<table class="phone-table">
|
||||
<tr>
|
||||
<td class="phone-prefix">{{ field.prefix }}</td>
|
||||
<td class="phone-value">
|
||||
{% if field.value %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
<span class="input-placeholder"> </span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% elif field.kind == 'signature' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="signature-box">
|
||||
{% if field.signature_src %}
|
||||
<img src="{{ field.signature_src }}" alt="Signature" />
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="input-box">
|
||||
{% if field.value %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
<span class="input-placeholder"> </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if field.field_type %}
|
||||
<div class="field-meta">Type: {{ field.field_type }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.0.3"
|
||||
__version__ = "0.0.4"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import FormRenderer from '@/components/Form/FormRenderer';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
import SignatureField from '@/components/Form/SignatureField';
|
||||
import Button from '@/components/Form/Button';
|
||||
import {
|
||||
CheckCircle,
|
||||
Hourglass,
|
||||
@ -9,6 +11,7 @@ import {
|
||||
Download,
|
||||
Upload,
|
||||
XCircle,
|
||||
PenTool,
|
||||
} from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
@ -20,6 +23,7 @@ import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
|
||||
* @param {Boolean} enable - Si les formulaires sont modifiables
|
||||
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
|
||||
* @param {Function} onSignatureSubmit - Callback appelé quand une signature est soumise
|
||||
*/
|
||||
export default function DynamicFormsList({
|
||||
schoolFileTemplates,
|
||||
@ -27,7 +31,8 @@ export default function DynamicFormsList({
|
||||
onFormSubmit,
|
||||
enable = true,
|
||||
onValidationChange,
|
||||
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
|
||||
onFileUpload,
|
||||
onSignatureSubmit,
|
||||
}) {
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
const [formsData, setFormsData] = useState({});
|
||||
@ -82,6 +87,19 @@ export default function DynamicFormsList({
|
||||
// vérifier si un fichier a déjà été uploadé sur le template
|
||||
const template = schoolFileTemplates.find((tpl) => tpl.id === templateId);
|
||||
if (template && template.file && !isDynamicForm(template)) {
|
||||
// Si le document est refusé, on ne le considère pas comme complété
|
||||
if (template.isValidated === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vérifier si le document a été signé électroniquement ET non refusé
|
||||
if (
|
||||
template &&
|
||||
template.is_electronically_signed &&
|
||||
template.isValidated !== false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -249,6 +267,30 @@ export default function DynamicFormsList({
|
||||
}
|
||||
};
|
||||
|
||||
// Handler pour soumettre la signature électronique
|
||||
const handleSignature = async (templateId, signatureData) => {
|
||||
if (!signatureData || !templateId) return;
|
||||
try {
|
||||
if (onSignatureSubmit) {
|
||||
await onSignatureSubmit(templateId, signatureData);
|
||||
setFormsData((prev) => ({
|
||||
...prev,
|
||||
[templateId]: { ...prev[templateId], signed: true },
|
||||
}));
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: true,
|
||||
}));
|
||||
// Passer au formulaire suivant si disponible
|
||||
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
|
||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la soumission de la signature :', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
@ -325,7 +367,8 @@ export default function DynamicFormsList({
|
||||
? 'text-secondary font-semibold'
|
||||
: textClass;
|
||||
canEdit = false;
|
||||
} else if (isValidated === false) {
|
||||
} else if (isValidated === false && !isCompletedLocally) {
|
||||
// Refusé uniquement si pas re-complété localement
|
||||
statusLabel = 'Refusé';
|
||||
statusColor = 'red';
|
||||
icon = <Hourglass className="w-5 h-5 text-red-500" />;
|
||||
@ -337,30 +380,28 @@ export default function DynamicFormsList({
|
||||
? 'text-red-900 font-semibold'
|
||||
: 'text-red-700';
|
||||
canEdit = true;
|
||||
} else 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 {
|
||||
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 = 'À compléter';
|
||||
statusColor = 'gray';
|
||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
bgClass = isActive ? 'bg-gray-200' : '';
|
||||
borderClass = isActive ? 'border border-gray-300' : '';
|
||||
textClass = isActive
|
||||
? 'text-gray-900 font-semibold'
|
||||
: 'text-gray-600';
|
||||
canEdit = true;
|
||||
}
|
||||
statusLabel = 'À compléter';
|
||||
statusColor = 'gray';
|
||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
bgClass = isActive ? 'bg-gray-200' : '';
|
||||
borderClass = isActive ? 'border border-gray-300' : '';
|
||||
textClass = isActive
|
||||
? 'text-gray-900 font-semibold'
|
||||
: 'text-gray-600';
|
||||
canEdit = true;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -417,14 +458,14 @@ export default function DynamicFormsList({
|
||||
<span className="px-2 py-0.5 rounded bg-primary/10 text-secondary text-sm font-semibold">
|
||||
Validé
|
||||
</span>
|
||||
) : 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>
|
||||
) : currentTemplate.isValidated === false ? (
|
||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||
Refusé
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-sm font-semibold">
|
||||
En attente
|
||||
@ -507,39 +548,137 @@ export default function DynamicFormsList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cas non validé : bouton télécharger + upload */}
|
||||
{/* Cas non validé */}
|
||||
{currentTemplate.isValidated !== true && (
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
{/* Bouton télécharger le document source (fichier maître) */}
|
||||
{(currentTemplate.master_file_url ||
|
||||
currentTemplate.file) && (
|
||||
<a
|
||||
href={getSecureFileUrl(
|
||||
currentTemplate.master_file_url ||
|
||||
currentTemplate.file
|
||||
{/* Document à signer électroniquement */}
|
||||
{currentTemplate?.requires_electronic_signature ? (
|
||||
<>
|
||||
{/* Affichage du document à signer */}
|
||||
{(currentTemplate.master_file_url ||
|
||||
currentTemplate.file) && (
|
||||
<iframe
|
||||
src={getSecureFileUrl(
|
||||
currentTemplate.master_file_url ||
|
||||
currentTemplate.file
|
||||
)}
|
||||
title={currentTemplate.name}
|
||||
className="w-full border rounded"
|
||||
style={{ height: '500px', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||
download
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Télécharger le document
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Composant d'upload */}
|
||||
{enable && (
|
||||
<FileUpload
|
||||
key={currentTemplate.id}
|
||||
selectionMessage={'Sélectionnez le fichier du document'}
|
||||
onFileSelect={(file) =>
|
||||
handleUpload(file, currentTemplate)
|
||||
}
|
||||
existingFile={
|
||||
currentTemplate.file_url || currentTemplate.file
|
||||
}
|
||||
required
|
||||
enable={true}
|
||||
/>
|
||||
{/* Afficher si déjà signé et non refusé */}
|
||||
{currentTemplate.is_electronically_signed &&
|
||||
currentTemplate.isValidated !== false ? (
|
||||
<div className="flex items-center gap-2 p-4 bg-green-50 border border-green-200 rounded-lg w-full max-w-md">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-800">
|
||||
Document signé électroniquement
|
||||
</p>
|
||||
{currentTemplate.electronic_signature_date && (
|
||||
<p className="text-sm text-green-600">
|
||||
Signé le{' '}
|
||||
{new Date(
|
||||
currentTemplate.electronic_signature_date
|
||||
).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Signature électronique si pas encore signé ou refusé */
|
||||
enable && (
|
||||
<div className="w-full max-w-md mt-4">
|
||||
{/* Message si document refusé */}
|
||||
{currentTemplate.isValidated === false && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
<p className="text-sm text-red-700">
|
||||
Ce document a été refusé. Veuillez le
|
||||
signer à nouveau.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SignatureField
|
||||
label="Signature électronique"
|
||||
required={true}
|
||||
value={
|
||||
formsData[currentTemplate.id]?.signature || ''
|
||||
}
|
||||
onChange={(signatureData) => {
|
||||
setFormsData((prev) => ({
|
||||
...prev,
|
||||
[currentTemplate.id]: {
|
||||
...(prev[currentTemplate.id] || {}),
|
||||
signature: signatureData,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
text="Signer le document"
|
||||
onClick={() =>
|
||||
handleSignature(
|
||||
currentTemplate.id,
|
||||
formsData[currentTemplate.id]?.signature
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!formsData[currentTemplate.id]?.signature
|
||||
}
|
||||
className="mt-4 w-full"
|
||||
icon={<PenTool className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Document classique : télécharger + upload */}
|
||||
{(currentTemplate.master_file_url ||
|
||||
currentTemplate.file) && (
|
||||
<a
|
||||
href={getSecureFileUrl(
|
||||
currentTemplate.master_file_url ||
|
||||
currentTemplate.file
|
||||
)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||
download
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Télécharger le document
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Composant d'upload */}
|
||||
{enable && (
|
||||
<FileUpload
|
||||
key={currentTemplate.id}
|
||||
selectionMessage={
|
||||
'Sélectionnez le fichier du document'
|
||||
}
|
||||
onFileSelect={(file) =>
|
||||
handleUpload(file, currentTemplate)
|
||||
}
|
||||
existingFile={
|
||||
currentTemplate.file_url || currentTemplate.file
|
||||
}
|
||||
required
|
||||
enable={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -639,6 +639,50 @@ export default function InscriptionFormShared({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSignatureSubmit = (templateId, signatureData) => {
|
||||
if (!templateId || !signatureData) {
|
||||
logger.error('Données manquantes pour la signature.');
|
||||
return Promise.reject(
|
||||
new Error('Données manquantes pour la signature.')
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = new FormData();
|
||||
updateData.append('electronic_signature', signatureData);
|
||||
|
||||
return editRegistrationSchoolFileTemplates(templateId, updateData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug('Signature électronique enregistrée avec succès :', response);
|
||||
|
||||
// Mettre à jour schoolFileTemplates avec la signature
|
||||
setSchoolFileTemplates((prevTemplates) =>
|
||||
prevTemplates.map((template) =>
|
||||
template.id === templateId
|
||||
? {
|
||||
...template,
|
||||
electronic_signature: response.data.electronic_signature,
|
||||
electronic_signature_date: response.data.electronic_signature_date,
|
||||
is_electronically_signed: true,
|
||||
// Repasser en attente si le document était refusé
|
||||
isValidated:
|
||||
response.data.isValidated !== undefined
|
||||
? response.data.isValidated
|
||||
: template.isValidated === false
|
||||
? null
|
||||
: template.isValidated,
|
||||
}
|
||||
: template
|
||||
)
|
||||
);
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de l\'enregistrement de la signature :', error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteFile = (templateId) => {
|
||||
const fileToDelete = uploadedFiles.find(
|
||||
(file) => parseInt(file.id) === templateId && file.fileName
|
||||
@ -970,6 +1014,7 @@ export default function InscriptionFormShared({
|
||||
onValidationChange={handleDynamicFormsValidationChange}
|
||||
enable={enable}
|
||||
onFileUpload={handleSchoolFileUpload}
|
||||
onSignatureSubmit={handleSignatureSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import logger from '@/utils/logger';
|
||||
import { School, FileText } from 'lucide-react';
|
||||
import { School, FileText, PenTool } from 'lucide-react';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import Button from '@/components/Form/Button';
|
||||
|
||||
@ -161,6 +161,10 @@ export default function ValidateSubscription({
|
||||
name: template.name || 'Document scolaire',
|
||||
file: template.file,
|
||||
description: template.description,
|
||||
is_electronically_signed: template.is_electronically_signed,
|
||||
electronic_signature: template.electronic_signature,
|
||||
electronic_signature_date: template.electronic_signature_date,
|
||||
requires_electronic_signature: template.requires_electronic_signature,
|
||||
})),
|
||||
...parentFileTemplates.map((template) => ({
|
||||
name: template.master_name || 'Document parent',
|
||||
@ -213,9 +217,19 @@ export default function ValidateSubscription({
|
||||
<div className="w-3/4">
|
||||
{currentTemplateIndex < allTemplates.length && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800 mb-4">
|
||||
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800">
|
||||
{allTemplates[currentTemplateIndex].name ||
|
||||
'Document sans nom'}
|
||||
</h3>
|
||||
{/* Badge signature électronique */}
|
||||
{allTemplates[currentTemplateIndex].is_electronically_signed && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-blue-100 text-blue-700 text-sm font-medium">
|
||||
<PenTool className="w-4 h-4" />
|
||||
Signé électroniquement
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<iframe
|
||||
src={
|
||||
allTemplates[currentTemplateIndex].type === 'main'
|
||||
@ -229,10 +243,49 @@ export default function ValidateSubscription({
|
||||
}
|
||||
className="w-full"
|
||||
style={{
|
||||
height: '75vh',
|
||||
height: allTemplates[currentTemplateIndex]
|
||||
.is_electronically_signed
|
||||
? '60vh'
|
||||
: '75vh',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Affichage de la signature électronique */}
|
||||
{allTemplates[currentTemplateIndex].is_electronically_signed &&
|
||||
allTemplates[currentTemplateIndex].electronic_signature && (
|
||||
<div className="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PenTool className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium text-gray-800">
|
||||
Signature électronique
|
||||
</span>
|
||||
{allTemplates[currentTemplateIndex]
|
||||
.electronic_signature_date && (
|
||||
<span className="text-sm text-gray-500">
|
||||
- Signé le{' '}
|
||||
{new Date(
|
||||
allTemplates[
|
||||
currentTemplateIndex
|
||||
].electronic_signature_date
|
||||
).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<img
|
||||
src={
|
||||
allTemplates[currentTemplateIndex].electronic_signature
|
||||
}
|
||||
alt="Signature électronique"
|
||||
className="max-w-xs h-auto border border-gray-300 rounded bg-white p-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -262,7 +315,27 @@ export default function ValidateSubscription({
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1">{template.name}</span>
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{template.name}
|
||||
{/* Badge signature électronique */}
|
||||
{template.is_electronically_signed && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 text-xs font-medium"
|
||||
title={`Signé le ${template.electronic_signature_date ? new Date(template.electronic_signature_date).toLocaleDateString('fr-FR') : ''}`}
|
||||
>
|
||||
<PenTool className="w-3 h-3" />
|
||||
Signé
|
||||
</span>
|
||||
)}
|
||||
{/* Alerte si signature requise mais pas signée */}
|
||||
{template.requires_electronic_signature &&
|
||||
!template.is_electronically_signed && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-medium">
|
||||
<PenTool className="w-3 h-3" />
|
||||
À signer
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* 2 boutons : Validé / Refusé (sauf fiche élève) */}
|
||||
{index !== 0 && (
|
||||
<span className="ml-2 flex gap-1">
|
||||
|
||||
@ -348,6 +348,7 @@ export default function FilesGroupsManagement({
|
||||
group_ids,
|
||||
formMasterData,
|
||||
file,
|
||||
requires_electronic_signature,
|
||||
},
|
||||
onCreated
|
||||
) => {
|
||||
@ -358,6 +359,7 @@ export default function FilesGroupsManagement({
|
||||
groups: group_ids,
|
||||
formMasterData,
|
||||
establishment: selectedEstablishmentId,
|
||||
requires_electronic_signature: requires_electronic_signature || false,
|
||||
};
|
||||
dataToSend.append('data', JSON.stringify(jsonData));
|
||||
if (file) {
|
||||
@ -402,6 +404,7 @@ export default function FilesGroupsManagement({
|
||||
formMasterData,
|
||||
id,
|
||||
file,
|
||||
requires_electronic_signature,
|
||||
}) => {
|
||||
// Correction : normaliser group_ids pour ne garder que les IDs (number/string)
|
||||
let normalizedGroupIds = [];
|
||||
@ -417,6 +420,7 @@ export default function FilesGroupsManagement({
|
||||
groups: normalizedGroupIds,
|
||||
formMasterData: formMasterData,
|
||||
establishment: selectedEstablishmentId,
|
||||
requires_electronic_signature: requires_electronic_signature || false,
|
||||
};
|
||||
dataToSend.append('data', JSON.stringify(jsonData));
|
||||
|
||||
@ -803,18 +807,12 @@ export default function FilesGroupsManagement({
|
||||
à droite de la liste des documents pour ajouter :
|
||||
</p>
|
||||
<ul className="list-disc list-inside ml-6">
|
||||
<li>
|
||||
<span className="text-yellow-700 font-semibold">
|
||||
Formulaire personnalisé
|
||||
</span>{' '}
|
||||
: créé dynamiquement par l'école, à remplir et/ou signer
|
||||
électroniquement par la famille.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-black font-semibold">
|
||||
Formulaire existant
|
||||
</span>{' '}
|
||||
: importez un PDF ou autre document à faire remplir.
|
||||
: importez un PDF ou autre document à faire remplir. Vous pouvez
|
||||
activer la signature électronique.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-orange-700 font-semibold">
|
||||
@ -962,16 +960,6 @@ export default function FilesGroupsManagement({
|
||||
</span>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<Star className="w-5 h-5 mr-2 text-yellow-600" />
|
||||
Formulaire personnalisé
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('formulaire'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
@ -1117,12 +1105,16 @@ export default function FilesGroupsManagement({
|
||||
group_ids: fileToEdit.groups,
|
||||
file: fileToEdit.file,
|
||||
formMasterData: fileToEdit.formMasterData,
|
||||
requires_electronic_signature:
|
||||
fileToEdit.requires_electronic_signature || false,
|
||||
});
|
||||
} else {
|
||||
handleCreateSchoolFileMaster({
|
||||
name: fileToEdit.name,
|
||||
group_ids: fileToEdit.groups,
|
||||
file: fileToEdit.file,
|
||||
requires_electronic_signature:
|
||||
fileToEdit.requires_electronic_signature || false,
|
||||
});
|
||||
}
|
||||
setIsFileUploadPopupOpen(false);
|
||||
@ -1199,6 +1191,22 @@ export default function FilesGroupsManagement({
|
||||
required
|
||||
enable
|
||||
/>
|
||||
<CheckBox
|
||||
item={{ id: 'signature' }}
|
||||
formData={{
|
||||
requires_electronic_signature:
|
||||
fileToEdit?.requires_electronic_signature || false,
|
||||
}}
|
||||
handleChange={() =>
|
||||
setFileToEdit({
|
||||
...fileToEdit,
|
||||
requires_electronic_signature:
|
||||
!fileToEdit?.requires_electronic_signature,
|
||||
})
|
||||
}
|
||||
fieldName="requires_electronic_signature"
|
||||
itemLabelFunc={() => 'À signer électroniquement'}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
type="submit"
|
||||
@ -1224,13 +1232,13 @@ export default function FilesGroupsManagement({
|
||||
!fileToEdit?.file
|
||||
)
|
||||
return;
|
||||
handleCreateSchoolFileMaster(
|
||||
{
|
||||
name: fileToEdit.name,
|
||||
group_ids: fileToEdit.groups,
|
||||
file: fileToEdit.file,
|
||||
}
|
||||
);
|
||||
handleCreateSchoolFileMaster({
|
||||
name: fileToEdit.name,
|
||||
group_ids: fileToEdit.groups,
|
||||
file: fileToEdit.file,
|
||||
requires_electronic_signature:
|
||||
fileToEdit.requires_electronic_signature || false,
|
||||
});
|
||||
setIsFileUploadPopupOpen(false);
|
||||
setFileToEdit(null);
|
||||
}}
|
||||
@ -1294,6 +1302,22 @@ export default function FilesGroupsManagement({
|
||||
required
|
||||
enable
|
||||
/>
|
||||
<CheckBox
|
||||
item={{ id: 'signature' }}
|
||||
formData={{
|
||||
requires_electronic_signature:
|
||||
fileToEdit?.requires_electronic_signature || false,
|
||||
}}
|
||||
handleChange={() =>
|
||||
setFileToEdit({
|
||||
...fileToEdit,
|
||||
requires_electronic_signature:
|
||||
!fileToEdit?.requires_electronic_signature,
|
||||
})
|
||||
}
|
||||
fieldName="requires_electronic_signature"
|
||||
itemLabelFunc={() => 'À signer électroniquement'}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user