fix: signature électronique

This commit is contained in:
N3WT DE COMPET
2026-04-05 16:06:04 +02:00
parent a81b76ecea
commit db587ec747
16 changed files with 654 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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