mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-29 07:53:23 +00:00
feat: Précablage du formulaire dynamique [N3WTS-17]
This commit is contained in:
@ -15,7 +15,6 @@ export default function Home() {
|
||||
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
|
||||
<p className="text-lg mb-8">{t('pleaseLogin')}</p>
|
||||
<Button text={t('loginButton')} primary href="/users/login" />
|
||||
<FormTemplateBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,10 +6,13 @@ import {
|
||||
BE_SUBSCRIPTION_ABSENCES_URL,
|
||||
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
|
||||
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export const editStudentCompetencies = (data, csrfToken) => {
|
||||
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
||||
@ -83,6 +86,45 @@ export const editRegisterForm = (id, data, csrfToken) => {
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||
try {
|
||||
// Version allégée pour auto-save - ne pas envoyer tous les fichiers
|
||||
const autoSaveData = new FormData();
|
||||
|
||||
// Ajouter seulement les données textuelles pour l'auto-save
|
||||
if (data.student) {
|
||||
autoSaveData.append('student_data', JSON.stringify(data.student));
|
||||
}
|
||||
if (data.guardians) {
|
||||
autoSaveData.append('guardians_data', JSON.stringify(data.guardians));
|
||||
}
|
||||
if (data.siblings) {
|
||||
autoSaveData.append('siblings_data', JSON.stringify(data.siblings));
|
||||
}
|
||||
if (data.currentPage) {
|
||||
autoSaveData.append('current_page', data.currentPage);
|
||||
}
|
||||
autoSaveData.append('auto_save', 'true');
|
||||
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: autoSaveData,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(() => {
|
||||
// Silent fail pour l'auto-save
|
||||
logger.debug('Auto-save failed silently');
|
||||
});
|
||||
} catch (error) {
|
||||
// Silent fail pour l'auto-save
|
||||
logger.debug('Auto-save error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createRegisterForm = (data, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
|
||||
return fetch(url, {
|
||||
@ -302,3 +344,68 @@ export const deleteAbsences = (id, csrfToken) => {
|
||||
credentials: 'include',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère les formulaires maîtres d'inscription pour un établissement
|
||||
* @param {number} establishmentId - ID de l'établissement
|
||||
* @returns {Promise<Array>} Liste des formulaires
|
||||
*/
|
||||
export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sauvegarde les réponses d'un formulaire dans RegistrationSchoolFileTemplate
|
||||
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||
* @param {Object} formTemplateData - Données du formulaire à sauvegarder
|
||||
* @param {string} csrfToken - Token CSRF
|
||||
* @returns {Promise} Résultat de la sauvegarde
|
||||
*/
|
||||
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
const payload = {
|
||||
formTemplateData: formTemplateData,
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Récupère les données sauvegardées d'un RegistrationSchoolFileTemplate
|
||||
* @param {number} templateId - ID du RegistrationSchoolFileTemplate
|
||||
* @returns {Promise<Object>} Template avec formTemplateData
|
||||
*/
|
||||
export const fetchFormResponses = (templateId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
63
Front-End/src/components/AutoSaveIndicator.js
Normal file
@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Composant indicateur de sauvegarde automatique
|
||||
* @param {Boolean} isSaving - Si la sauvegarde est en cours
|
||||
* @param {Date} lastSaved - Date de la dernière sauvegarde
|
||||
* @param {Boolean} autoSaveEnabled - Si l'auto-save est activée
|
||||
* @param {Function} onToggleAutoSave - Callback pour activer/désactiver l'auto-save
|
||||
*/
|
||||
export default function AutoSaveIndicator({
|
||||
isSaving = false,
|
||||
lastSaved = null,
|
||||
autoSaveEnabled = true,
|
||||
onToggleAutoSave = null,
|
||||
}) {
|
||||
if (!autoSaveEnabled && !lastSaved && !isSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2 px-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-blue-600 font-medium">
|
||||
Sauvegarde en cours...
|
||||
</span>
|
||||
</>
|
||||
) : lastSaved ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">
|
||||
Sauvegardé à {lastSaved.toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">Auto-sauvegarde activée</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onToggleAutoSave && (
|
||||
<button
|
||||
onClick={onToggleAutoSave}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
|
||||
autoSaveEnabled
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
title={
|
||||
autoSaveEnabled
|
||||
? 'Désactiver la sauvegarde automatique'
|
||||
: 'Activer la sauvegarde automatique'
|
||||
}
|
||||
>
|
||||
{autoSaveEnabled ? '✓ Auto-save' : '○ Auto-save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import Button from './Button';
|
||||
import IconSelector from './IconSelector';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
import FIELD_TYPES_WITH_ICONS from './FieldTypesWithIcons';
|
||||
|
||||
export default function AddFieldModal({
|
||||
isOpen,
|
||||
@ -55,6 +56,10 @@ export default function AddFieldModal({
|
||||
acceptTypes: '',
|
||||
maxSize: 5,
|
||||
checked: false,
|
||||
signatureData: '',
|
||||
backgroundColor: '#ffffff',
|
||||
penColor: '#000000',
|
||||
penWidth: 2,
|
||||
validation: {
|
||||
pattern: '',
|
||||
minLength: '',
|
||||
@ -62,6 +67,16 @@ export default function AddFieldModal({
|
||||
},
|
||||
};
|
||||
|
||||
// Si un type a été présélectionné depuis le sélecteur de type
|
||||
if (editingField && !isEditing) {
|
||||
// S'assurer que le type est correctement défini
|
||||
if (typeof editingField.type === 'string') {
|
||||
defaultValues.type = editingField.type;
|
||||
} else if (editingField.value) {
|
||||
defaultValues.type = editingField.value;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentField(defaultValues);
|
||||
|
||||
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
|
||||
@ -75,17 +90,32 @@ export default function AddFieldModal({
|
||||
acceptTypes: defaultValues.acceptTypes,
|
||||
maxSize: defaultValues.maxSize,
|
||||
checked: defaultValues.checked,
|
||||
signatureData: defaultValues.signatureData,
|
||||
backgroundColor: defaultValues.backgroundColor,
|
||||
penColor: defaultValues.penColor,
|
||||
penWidth: defaultValues.penWidth,
|
||||
validation: defaultValues.validation,
|
||||
});
|
||||
}
|
||||
}, [isOpen, editingField, reset]);
|
||||
}, [isOpen, editingField, reset, isEditing]);
|
||||
|
||||
// Ajouter une option au select
|
||||
const addOption = () => {
|
||||
const addOption = (e) => {
|
||||
// Arrêter la propagation de l'événement pour éviter que le clic n'atteigne l'arrière-plan
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (newOption.trim()) {
|
||||
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||
const currentOptions = Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: [];
|
||||
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
options: [...currentField.options, newOption.trim()],
|
||||
options: [...currentOptions, newOption.trim()],
|
||||
});
|
||||
setNewOption('');
|
||||
}
|
||||
@ -93,7 +123,12 @@ export default function AddFieldModal({
|
||||
|
||||
// Supprimer une option du select
|
||||
const removeOption = (index) => {
|
||||
const newOptions = currentField.options.filter((_, i) => i !== index);
|
||||
// Vérifie si options existe et est un tableau, sinon initialise comme tableau vide
|
||||
const currentOptions = Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: [];
|
||||
const newOptions = currentOptions.filter((_, i) => i !== index);
|
||||
|
||||
setCurrentField({ ...currentField, options: newOptions });
|
||||
};
|
||||
|
||||
@ -141,15 +176,28 @@ export default function AddFieldModal({
|
||||
name="type"
|
||||
selected={value}
|
||||
callback={(e) => {
|
||||
onChange(e.target.value);
|
||||
const newType = e.target.value;
|
||||
onChange(newType);
|
||||
|
||||
// Assurons-nous que les options restent un tableau si on sélectionne select ou radio
|
||||
let updatedOptions = currentField.options;
|
||||
|
||||
// Si options n'existe pas ou n'est pas un tableau, initialiser comme tableau vide
|
||||
if (!updatedOptions || !Array.isArray(updatedOptions)) {
|
||||
updatedOptions = [];
|
||||
}
|
||||
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
type: e.target.value,
|
||||
type: newType,
|
||||
options: updatedOptions,
|
||||
});
|
||||
}}
|
||||
choices={FIELD_TYPES}
|
||||
choices={FIELD_TYPES_WITH_ICONS}
|
||||
placeHolder="Sélectionner un type"
|
||||
required
|
||||
showIcons={true}
|
||||
customSelect={true}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -353,21 +401,22 @@ export default function AddFieldModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
{Array.isArray(currentField.options) &&
|
||||
currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -396,21 +445,22 @@ export default function AddFieldModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
{Array.isArray(currentField.options) &&
|
||||
currentField.options.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span>{option}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -484,6 +534,81 @@ export default function AddFieldModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentField.type === 'signature' && (
|
||||
<>
|
||||
<Controller
|
||||
name="backgroundColor"
|
||||
control={control}
|
||||
defaultValue={currentField.backgroundColor || '#ffffff'}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Couleur de fond
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
backgroundColor: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="penColor"
|
||||
control={control}
|
||||
defaultValue={currentField.penColor || '#000000'}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Couleur du stylo
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
penColor: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full h-10 border border-gray-300 rounded-md cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="penWidth"
|
||||
control={control}
|
||||
defaultValue={currentField.penWidth || 2}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputTextIcon
|
||||
label="Épaisseur du stylo (px)"
|
||||
name="penWidth"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(parseInt(e.target.value));
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
penWidth: parseInt(e.target.value) || 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentField.type === 'checkbox' && (
|
||||
<>
|
||||
<div className="flex items-center mt-2">
|
||||
|
||||
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
121
Front-End/src/components/Form/FieldTypeSelector.js
Normal file
@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import Button from './Button';
|
||||
|
||||
// Utiliser les mêmes icônes que dans FormTemplateBuilder
|
||||
const FIELD_TYPES_ICON = {
|
||||
text: { icon: LucideIcons.TextCursorInput },
|
||||
email: { icon: LucideIcons.AtSign },
|
||||
phone: { icon: LucideIcons.Phone },
|
||||
date: { icon: LucideIcons.Calendar },
|
||||
select: { icon: LucideIcons.ChevronDown },
|
||||
radio: { icon: LucideIcons.Radio },
|
||||
checkbox: { icon: LucideIcons.CheckSquare },
|
||||
toggle: { icon: LucideIcons.ToggleLeft },
|
||||
file: { icon: LucideIcons.FileUp },
|
||||
signature: { icon: LucideIcons.PenTool },
|
||||
textarea: { icon: LucideIcons.Type },
|
||||
paragraph: { icon: LucideIcons.AlignLeft },
|
||||
heading1: { icon: LucideIcons.Heading1 },
|
||||
heading2: { icon: LucideIcons.Heading2 },
|
||||
heading3: { icon: LucideIcons.Heading3 },
|
||||
heading4: { icon: LucideIcons.Heading4 },
|
||||
heading5: { icon: LucideIcons.Heading5 },
|
||||
heading6: { icon: LucideIcons.Heading6 },
|
||||
};
|
||||
|
||||
export default function FieldTypeSelector({ isOpen, onClose, onSelect }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Filtrer les types de champs selon le terme de recherche
|
||||
const filteredFieldTypes = FIELD_TYPES.filter((fieldType) =>
|
||||
fieldType.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const selectFieldType = (fieldType) => {
|
||||
onSelect(fieldType);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Choisir un type de champ ({filteredFieldTypes.length} /{' '}
|
||||
{FIELD_TYPES.length} types)
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un type de champ..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<LucideIcons.Search
|
||||
className="absolute left-3 top-3.5 text-gray-400"
|
||||
size={18}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<LucideIcons.X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredFieldTypes.map((fieldType) => {
|
||||
const IconComponent = FIELD_TYPES_ICON[fieldType.value]?.icon;
|
||||
return (
|
||||
<button
|
||||
key={fieldType.value}
|
||||
onClick={() => selectFieldType(fieldType)}
|
||||
className="p-5 rounded-lg border-2 border-gray-200 bg-gray-50
|
||||
hover:bg-blue-50 hover:border-blue-300 hover:shadow-md hover:scale-105
|
||||
flex flex-col items-center justify-center gap-4 min-h-[140px] w-full
|
||||
transition-all duration-200"
|
||||
title={fieldType.label}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={32}
|
||||
className="text-gray-700 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-gray-600 text-center font-medium">
|
||||
{fieldType.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
text="Annuler"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
73
Front-End/src/components/Form/FieldTypesWithIcons.js
Normal file
@ -0,0 +1,73 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
|
||||
// Associer les icônes à chaque type de champ
|
||||
export const FIELD_TYPES_WITH_ICONS = FIELD_TYPES.map((fieldType) => {
|
||||
let icon = null;
|
||||
|
||||
switch (fieldType.value) {
|
||||
case 'text':
|
||||
icon = LucideIcons.TextCursorInput;
|
||||
break;
|
||||
case 'email':
|
||||
icon = LucideIcons.AtSign;
|
||||
break;
|
||||
case 'phone':
|
||||
icon = LucideIcons.Phone;
|
||||
break;
|
||||
case 'date':
|
||||
icon = LucideIcons.Calendar;
|
||||
break;
|
||||
case 'select':
|
||||
icon = LucideIcons.ChevronDown;
|
||||
break;
|
||||
case 'radio':
|
||||
icon = LucideIcons.Radio;
|
||||
break;
|
||||
case 'checkbox':
|
||||
icon = LucideIcons.CheckSquare;
|
||||
break;
|
||||
case 'toggle':
|
||||
icon = LucideIcons.ToggleLeft;
|
||||
break;
|
||||
case 'file':
|
||||
icon = LucideIcons.FileUp;
|
||||
break;
|
||||
case 'signature':
|
||||
icon = LucideIcons.PenTool;
|
||||
break;
|
||||
case 'textarea':
|
||||
icon = LucideIcons.Type;
|
||||
break;
|
||||
case 'paragraph':
|
||||
icon = LucideIcons.AlignLeft;
|
||||
break;
|
||||
case 'heading1':
|
||||
icon = LucideIcons.Heading1;
|
||||
break;
|
||||
case 'heading2':
|
||||
icon = LucideIcons.Heading2;
|
||||
break;
|
||||
case 'heading3':
|
||||
icon = LucideIcons.Heading3;
|
||||
break;
|
||||
case 'heading4':
|
||||
icon = LucideIcons.Heading4;
|
||||
break;
|
||||
case 'heading5':
|
||||
icon = LucideIcons.Heading5;
|
||||
break;
|
||||
case 'heading6':
|
||||
icon = LucideIcons.Heading6;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldType,
|
||||
icon,
|
||||
};
|
||||
});
|
||||
|
||||
export default FIELD_TYPES_WITH_ICONS;
|
||||
@ -11,6 +11,7 @@ import CheckBox from './CheckBox';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import InputPhone from './InputPhone';
|
||||
import FileUpload from './FileUpload';
|
||||
import SignatureField from './SignatureField';
|
||||
|
||||
/*
|
||||
* Récupère une icône Lucide par son nom.
|
||||
@ -24,46 +25,8 @@ export function getIcon(name) {
|
||||
}
|
||||
}
|
||||
|
||||
const formConfigTest = {
|
||||
id: 0,
|
||||
title: 'Mon formulaire dynamique',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: [
|
||||
{ id: 'name', label: 'Nom', type: 'text', required: true },
|
||||
{ id: 'email', label: 'Email', type: 'email' },
|
||||
{
|
||||
id: 'email2',
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
icon: 'Mail',
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
label: 'Rôle',
|
||||
type: 'select',
|
||||
options: ['Admin', 'Utilisateur', 'Invité'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: "Bonjour, Bienvenue dans ce formulaire d'inscription haha",
|
||||
},
|
||||
{
|
||||
id: 'birthdate',
|
||||
label: 'Date de naissance',
|
||||
type: 'date',
|
||||
icon: 'Calendar',
|
||||
},
|
||||
{
|
||||
id: 'textarea',
|
||||
label: 'toto',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function FormRenderer({
|
||||
formConfig = formConfigTest,
|
||||
formConfig,
|
||||
csrfToken,
|
||||
onFormSubmit = (data) => {
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
@ -109,7 +72,8 @@ export default function FormRenderer({
|
||||
const hasFiles = Object.keys(data).some((key) => {
|
||||
return (
|
||||
data[key] instanceof FileList ||
|
||||
(data[key] && data[key][0] instanceof File)
|
||||
(data[key] && data[key][0] instanceof File) ||
|
||||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
|
||||
);
|
||||
});
|
||||
|
||||
@ -118,7 +82,7 @@ export default function FormRenderer({
|
||||
const formData = new FormData();
|
||||
|
||||
// Ajouter l'ID du formulaire
|
||||
formData.append('formId', formConfig.id.toString());
|
||||
formData.append('formId', (formConfig?.id || 'unknown').toString());
|
||||
|
||||
// Traiter chaque champ et ses valeurs
|
||||
Object.keys(data).forEach((key) => {
|
||||
@ -134,6 +98,29 @@ 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(
|
||||
@ -154,7 +141,7 @@ export default function FormRenderer({
|
||||
} else {
|
||||
// Pas de fichier, on peut utiliser JSON
|
||||
const formattedData = {
|
||||
formId: formConfig.id,
|
||||
formId: formConfig?.id || 'unknown',
|
||||
responses: { ...data },
|
||||
};
|
||||
|
||||
@ -189,10 +176,10 @@ export default function FormRenderer({
|
||||
>
|
||||
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
|
||||
<h2 className="text-2xl font-bold text-center mb-4">
|
||||
{formConfig.title}
|
||||
{formConfig?.title || 'Formulaire'}
|
||||
</h2>
|
||||
|
||||
{formConfig.fields.map((field) => (
|
||||
{(formConfig?.fields || []).map((field) => (
|
||||
<div
|
||||
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
|
||||
className="flex flex-col mt-4"
|
||||
@ -428,13 +415,40 @@ export default function FormRenderer({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{field.type === 'signature' && (
|
||||
<Controller
|
||||
name={field.id}
|
||||
control={control}
|
||||
rules={{ required: field.required }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<SignatureField
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
backgroundColor={field.backgroundColor || '#ffffff'}
|
||||
penColor={field.penColor || '#000000'}
|
||||
penWidth={field.penWidth || 2}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{field.required
|
||||
? `${field.label} est requis`
|
||||
: 'Champ invalide'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="form-group-submit mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
primary
|
||||
text={formConfig.submitLabel ? formConfig.submitLabel : 'Envoyer'}
|
||||
text={formConfig?.submitLabel || 'Envoyer'}
|
||||
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import FormRenderer from './FormRenderer';
|
||||
import AddFieldModal from './AddFieldModal';
|
||||
import FieldTypeSelector from './FieldTypeSelector';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import {
|
||||
@ -34,6 +35,7 @@ import {
|
||||
ToggleLeft,
|
||||
CheckSquare,
|
||||
FileUp,
|
||||
PenTool,
|
||||
} from 'lucide-react';
|
||||
|
||||
const FIELD_TYPES_ICON = {
|
||||
@ -46,6 +48,7 @@ const FIELD_TYPES_ICON = {
|
||||
checkbox: { icon: CheckSquare },
|
||||
toggle: { icon: ToggleLeft },
|
||||
file: { icon: FileUp },
|
||||
signature: { icon: PenTool },
|
||||
textarea: { icon: Type },
|
||||
paragraph: { icon: AlignLeft },
|
||||
heading1: { icon: Heading1 },
|
||||
@ -168,15 +171,26 @@ const DraggableFieldItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default function FormTemplateBuilder() {
|
||||
export default function FormTemplateBuilder({
|
||||
onSave,
|
||||
initialData,
|
||||
groups,
|
||||
isEditing,
|
||||
}) {
|
||||
const [formConfig, setFormConfig] = useState({
|
||||
id: 0,
|
||||
title: 'Nouveau formulaire',
|
||||
id: initialData?.id || 0,
|
||||
title: initialData?.name || 'Nouveau formulaire',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: [],
|
||||
fields: initialData?.formMasterData?.fields || [],
|
||||
});
|
||||
|
||||
const [selectedGroups, setSelectedGroups] = useState(
|
||||
initialData?.groups?.map((g) => g.id) || []
|
||||
);
|
||||
|
||||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||
const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false);
|
||||
const [selectedFieldType, setSelectedFieldType] = useState(null);
|
||||
const [editingIndex, setEditingIndex] = useState(-1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
|
||||
@ -185,6 +199,19 @@ export default function FormTemplateBuilder() {
|
||||
|
||||
const { reset: resetField } = useForm();
|
||||
|
||||
// Initialiser les données du formulaire quand initialData change
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormConfig({
|
||||
id: initialData.id || 0,
|
||||
title: initialData.name || 'Nouveau formulaire',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: initialData.formMasterData?.fields || [],
|
||||
});
|
||||
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// Gérer l'affichage du bouton de défilement
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@ -235,7 +262,9 @@ export default function FormTemplateBuilder() {
|
||||
? undefined
|
||||
: generateFieldId(data.label || 'field'),
|
||||
options: ['select', 'radio'].includes(data.type)
|
||||
? currentField.options
|
||||
? Array.isArray(currentField.options)
|
||||
? currentField.options
|
||||
: []
|
||||
: undefined,
|
||||
icon: data.icon || currentField.icon || undefined,
|
||||
placeholder: data.placeholder || undefined,
|
||||
@ -345,35 +374,36 @@ export default function FormTemplateBuilder() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedGroups.length === 0) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: "Sélectionnez au moins un groupe d'inscription",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveMessage({ type: '', text: '' });
|
||||
|
||||
try {
|
||||
// Simulation d'envoi au backend (à remplacer par l'appel API réel)
|
||||
// const response = await fetch('/api/form-templates', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(formConfig),
|
||||
// });
|
||||
const dataToSave = {
|
||||
name: formConfig.title,
|
||||
group_ids: selectedGroups,
|
||||
formMasterData: formConfig,
|
||||
};
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error('Erreur lors de l\'enregistrement du formulaire');
|
||||
// }
|
||||
if (isEditing && initialData) {
|
||||
dataToSave.id = initialData.id;
|
||||
}
|
||||
|
||||
// const data = await response.json();
|
||||
|
||||
// Simulation d'une réponse du backend
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (onSave) {
|
||||
onSave(dataToSave);
|
||||
}
|
||||
|
||||
setSaveMessage({
|
||||
type: 'success',
|
||||
text: 'Formulaire enregistré avec succès',
|
||||
});
|
||||
|
||||
// Si le backend renvoie un ID, on peut mettre à jour l'ID du formulaire
|
||||
// setFormConfig({ ...formConfig, id: data.id });
|
||||
} catch (error) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
@ -385,6 +415,13 @@ export default function FormTemplateBuilder() {
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour gérer la sélection d'un type de champ
|
||||
const handleFieldTypeSelect = (fieldType) => {
|
||||
setSelectedFieldType(fieldType);
|
||||
setShowFieldTypeSelector(false);
|
||||
setShowAddFieldModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
@ -476,6 +513,44 @@ export default function FormTemplateBuilder() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Sélecteur de groupes */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Groupes d'inscription{' '}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||
{groups && groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<label key={group.id} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGroups.includes(group.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedGroups([
|
||||
...selectedGroups,
|
||||
group.id,
|
||||
]);
|
||||
} else {
|
||||
setSelectedGroups(
|
||||
selectedGroups.filter((id) => id !== group.id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mr-2 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{group.name}</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Aucun groupe disponible
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des champs */}
|
||||
@ -487,7 +562,8 @@ export default function FormTemplateBuilder() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(true);
|
||||
setSelectedFieldType(null);
|
||||
setShowFieldTypeSelector(true);
|
||||
}}
|
||||
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
title="Ajouter un champ"
|
||||
@ -504,7 +580,8 @@ export default function FormTemplateBuilder() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(true);
|
||||
setSelectedFieldType(null);
|
||||
setShowFieldTypeSelector(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
@ -593,11 +670,22 @@ export default function FormTemplateBuilder() {
|
||||
onClose={() => setShowAddFieldModal(false)}
|
||||
onSubmit={handleFieldSubmit}
|
||||
editingField={
|
||||
editingIndex >= 0 ? formConfig.fields[editingIndex] : null
|
||||
editingIndex >= 0
|
||||
? formConfig.fields[editingIndex]
|
||||
: selectedFieldType
|
||||
? { type: selectedFieldType.value || selectedFieldType }
|
||||
: null
|
||||
}
|
||||
editingIndex={editingIndex}
|
||||
/>
|
||||
|
||||
{/* Sélecteur de type de champ */}
|
||||
<FieldTypeSelector
|
||||
isOpen={showFieldTypeSelector}
|
||||
onClose={() => setShowFieldTypeSelector(false)}
|
||||
onSelect={handleFieldTypeSelect}
|
||||
/>
|
||||
|
||||
{/* Bouton flottant pour remonter en haut */}
|
||||
{showScrollButton && (
|
||||
<div className="fixed bottom-6 right-6 z-10">
|
||||
|
||||
@ -8,6 +8,7 @@ export const FIELD_TYPES = [
|
||||
{ value: 'checkbox', label: 'Case à cocher' },
|
||||
{ value: 'toggle', label: 'Interrupteur' },
|
||||
{ value: 'file', label: 'Upload de fichier' },
|
||||
{ value: 'signature', label: 'Signature' },
|
||||
{ value: 'textarea', label: 'Zone de texte riche' },
|
||||
{ value: 'paragraph', label: 'Paragraphe' },
|
||||
{ value: 'heading1', label: 'Titre 1' },
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export default function SelectChoice({
|
||||
type,
|
||||
name,
|
||||
@ -11,8 +13,46 @@ export default function SelectChoice({
|
||||
errorLocalMsg,
|
||||
IconItem,
|
||||
disabled = false,
|
||||
// Nouveaux paramètres
|
||||
showIcons = false, // Activer l'affichage des icônes dans les options
|
||||
customSelect = false, // Utiliser une liste personnalisée au lieu du select natif
|
||||
}) {
|
||||
const isPlaceholderSelected = selected === ''; // Vérifie si le placeholder est sélectionné
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedLabel, setSelectedLabel] = useState('');
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Ferme le dropdown si on clique en dehors
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (customSelect) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [customSelect]);
|
||||
|
||||
// Met à jour le label affiché quand selected change
|
||||
useEffect(() => {
|
||||
if (selected && choices) {
|
||||
const selectedOption = choices.find(
|
||||
(option) => option.value === selected
|
||||
);
|
||||
if (selectedOption) {
|
||||
setSelectedLabel(selectedOption.label);
|
||||
}
|
||||
} else {
|
||||
setSelectedLabel('');
|
||||
}
|
||||
}, [selected, choices]);
|
||||
|
||||
// Affiche soit le select natif, soit notre version personnalisée avec icônes
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@ -23,45 +63,172 @@ export default function SelectChoice({
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
||||
>
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
||||
disabled ? 'bg-gray-100' : ''
|
||||
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`} // Applique le style classique si une option autre que le placeholder est sélectionnée
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
value={selected}
|
||||
onChange={callback}
|
||||
disabled={disabled}
|
||||
|
||||
{customSelect ? (
|
||||
// Version personnalisée avec icônes dans un dropdown
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md cursor-pointer ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 px-3 py-2 block w-full sm:text-sm">
|
||||
{isPlaceholderSelected ? (
|
||||
<span className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
{showIcons && selected && choices && (
|
||||
<span className="mr-2">
|
||||
{(() => {
|
||||
const selectedOption = choices.find(
|
||||
(option) => option.value === selected
|
||||
);
|
||||
if (selectedOption && selectedOption.icon) {
|
||||
const IconComponent = selectedOption.icon;
|
||||
return (
|
||||
<IconComponent
|
||||
size={18}
|
||||
className="text-gray-600"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-800">{selectedLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pr-2">
|
||||
<svg
|
||||
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white shadow-lg rounded-md py-1 max-h-60 overflow-auto">
|
||||
<div
|
||||
className="px-3 py-2 text-gray-500 italic cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const event = { target: { value: '' } };
|
||||
callback(event);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placeHolder?.toLowerCase()}
|
||||
</div>
|
||||
{choices.map(({ value, label, icon: Icon }) => (
|
||||
<div
|
||||
key={value}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 ${
|
||||
value === selected
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-800'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const event = { target: { value } };
|
||||
callback(event);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{showIcons && Icon && (
|
||||
<span className="mr-2">
|
||||
<Icon
|
||||
size={18}
|
||||
className={
|
||||
value === selected
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-600'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input caché pour la compatibilité avec les formulaires */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
id={name}
|
||||
value={selected || ''}
|
||||
onChange={() => {}} // Évite l'avertissement React pour input non contrôlé
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Version standard avec select natif (pour la rétrocompatibilité)
|
||||
<div
|
||||
className={`mt-1 flex items-center border rounded-md ${
|
||||
errorMsg || errorLocalMsg
|
||||
? 'border-red-500 hover:border-red-700'
|
||||
: 'border-gray-200 hover:border-gray-400'
|
||||
} ${disabled ? '' : 'focus-within:border-gray-500'}`}
|
||||
>
|
||||
{/* Placeholder en italique */}
|
||||
<option value="" className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</option>
|
||||
{/* Autres options sans italique */}
|
||||
{choices.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
className="not-italic text-gray-800"
|
||||
>
|
||||
{label}
|
||||
{IconItem && (
|
||||
<span className="inline-flex items-center px-3 text-gray-500 text-sm">
|
||||
{<IconItem />}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
className={`flex-1 px-3 py-2 block w-full sm:text-sm border-none focus:ring-0 outline-none rounded-md ${
|
||||
disabled ? 'bg-gray-100' : ''
|
||||
} ${isPlaceholderSelected ? 'italic text-gray-500' : 'not-italic text-gray-800'}`}
|
||||
type={type}
|
||||
id={name}
|
||||
name={name}
|
||||
value={selected}
|
||||
onChange={callback}
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* Placeholder en italique */}
|
||||
<option value="" className="italic text-gray-500">
|
||||
{placeHolder?.toLowerCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Autres options sans italique */}
|
||||
{choices.map(({ value, label }) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
className="not-italic text-gray-800"
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMsg && <p className="mt-2 text-sm text-red-600">{errorMsg}</p>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
346
Front-End/src/components/Form/SignatureField.js
Normal file
346
Front-End/src/components/Form/SignatureField.js
Normal file
@ -0,0 +1,346 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
const SignatureField = ({
|
||||
label = 'Signature',
|
||||
required = false,
|
||||
onChange,
|
||||
value,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
backgroundColor = '#ffffff',
|
||||
penColor = '#000000',
|
||||
penWidth = 2,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState({ x: 0, y: 0 });
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [svgPaths, setSvgPaths] = useState([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [smoothingPoints, setSmoothingPoints] = useState([]);
|
||||
|
||||
// Initialiser le canvas
|
||||
const initializeCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// 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;
|
||||
|
||||
// Maintenir la taille d'affichage
|
||||
canvas.style.width = displayWidth + 'px';
|
||||
canvas.style.height = displayHeight + 'px';
|
||||
|
||||
// Adapter le contexte à la densité de pixels
|
||||
context.scale(devicePixelRatio, devicePixelRatio);
|
||||
|
||||
// Améliorer l'anti-aliasing et le rendu
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.textRenderingOptimization = 'optimizeQuality';
|
||||
|
||||
// Configuration du style de dessin
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
context.strokeStyle = penColor;
|
||||
context.lineWidth = penWidth;
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
}, [backgroundColor, penColor, penWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeCanvas();
|
||||
|
||||
// Si une valeur est fournie (signature existante), la charger
|
||||
if (value && value !== '') {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (value.includes('svg+xml')) {
|
||||
// Charger une signature SVG
|
||||
const svgData = atob(value.split(',')[1]);
|
||||
const img = new Image();
|
||||
const svg = new Blob([svgData], {
|
||||
type: 'image/svg+xml;charset=utf-8',
|
||||
});
|
||||
const url = URL.createObjectURL(svg);
|
||||
|
||||
img.onload = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(img, 0, 0);
|
||||
setIsEmpty(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
img.src = url;
|
||||
} else {
|
||||
// Charger une image classique
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(img, 0, 0);
|
||||
setIsEmpty(false);
|
||||
};
|
||||
img.src = value;
|
||||
}
|
||||
}
|
||||
}, [value, initializeCanvas, backgroundColor]);
|
||||
|
||||
// Obtenir les coordonnées relatives au canvas
|
||||
const getCanvasPosition = (e) => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.style.width
|
||||
? parseFloat(canvas.style.width) / rect.width
|
||||
: 1;
|
||||
const scaleY = canvas.style.height
|
||||
? parseFloat(canvas.style.height) / rect.height
|
||||
: 1;
|
||||
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Obtenir les coordonnées pour les événements tactiles
|
||||
const getTouchPosition = (e) => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.style.width
|
||||
? parseFloat(canvas.style.width) / rect.width
|
||||
: 1;
|
||||
const scaleY = canvas.style.height
|
||||
? parseFloat(canvas.style.height) / rect.height
|
||||
: 1;
|
||||
|
||||
return {
|
||||
x: (e.touches[0].clientX - rect.left) * scaleX,
|
||||
y: (e.touches[0].clientY - rect.top) * scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
// Commencer le dessin
|
||||
const startDrawing = useCallback(
|
||||
(e) => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDrawing(true);
|
||||
|
||||
const pos = e.type.includes('touch')
|
||||
? getTouchPosition(e)
|
||||
: getCanvasPosition(e);
|
||||
setLastPosition(pos);
|
||||
|
||||
// Commencer un nouveau path SVG
|
||||
setCurrentPath(`M ${pos.x},${pos.y}`);
|
||||
setSmoothingPoints([pos]);
|
||||
},
|
||||
[disabled, readOnly]
|
||||
);
|
||||
|
||||
// Dessiner
|
||||
const draw = useCallback(
|
||||
(e) => {
|
||||
if (!isDrawing || disabled || readOnly) return;
|
||||
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
const currentPos = e.type.includes('touch')
|
||||
? getTouchPosition(e)
|
||||
: getCanvasPosition(e);
|
||||
|
||||
// Calculer la distance pour déterminer si on doit interpoler
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(currentPos.x - lastPosition.x, 2) +
|
||||
Math.pow(currentPos.y - lastPosition.y, 2)
|
||||
);
|
||||
|
||||
// Si la distance est grande, interpoler pour un tracé plus lisse
|
||||
if (distance > 2) {
|
||||
const midPoint = {
|
||||
x: (lastPosition.x + currentPos.x) / 2,
|
||||
y: (lastPosition.y + currentPos.y) / 2,
|
||||
};
|
||||
|
||||
// Utiliser une courbe quadratique pour un tracé plus lisse
|
||||
context.beginPath();
|
||||
context.moveTo(lastPosition.x, lastPosition.y);
|
||||
context.quadraticCurveTo(
|
||||
lastPosition.x,
|
||||
lastPosition.y,
|
||||
midPoint.x,
|
||||
midPoint.y
|
||||
);
|
||||
context.stroke();
|
||||
|
||||
setLastPosition(midPoint);
|
||||
setCurrentPath(
|
||||
(prev) =>
|
||||
prev +
|
||||
` Q ${lastPosition.x},${lastPosition.y} ${midPoint.x},${midPoint.y}`
|
||||
);
|
||||
} else {
|
||||
// Tracé direct pour les mouvements lents
|
||||
context.beginPath();
|
||||
context.moveTo(lastPosition.x, lastPosition.y);
|
||||
context.lineTo(currentPos.x, currentPos.y);
|
||||
context.stroke();
|
||||
|
||||
setLastPosition(currentPos);
|
||||
setCurrentPath((prev) => prev + ` L ${currentPos.x},${currentPos.y}`);
|
||||
}
|
||||
|
||||
setIsEmpty(false);
|
||||
},
|
||||
[isDrawing, lastPosition, disabled]
|
||||
);
|
||||
|
||||
// Arrêter le dessin
|
||||
const stopDrawing = useCallback(
|
||||
(e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDrawing(false);
|
||||
|
||||
// Ajouter le path terminé aux paths SVG
|
||||
if (currentPath) {
|
||||
setSvgPaths((prev) => [...prev, currentPath]);
|
||||
setCurrentPath('');
|
||||
}
|
||||
|
||||
// Notifier le parent du changement avec SVG
|
||||
if (onChange) {
|
||||
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
|
||||
const svgData = generateSVG(newPaths);
|
||||
onChange(svgData);
|
||||
}
|
||||
},
|
||||
[isDrawing, onChange, svgPaths, currentPath]
|
||||
);
|
||||
|
||||
// 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">
|
||||
<rect width="100%" height="100%" fill="${backgroundColor}"/>
|
||||
${paths
|
||||
.map(
|
||||
(path) =>
|
||||
`<path d="${path}" stroke="${penColor}" stroke-width="${penWidth}" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`
|
||||
)
|
||||
.join('\n ')}
|
||||
</svg>`;
|
||||
|
||||
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
};
|
||||
|
||||
// Effacer la signature
|
||||
const clearSignature = () => {
|
||||
const canvas = canvasRef.current;
|
||||
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);
|
||||
setIsEmpty(true);
|
||||
setSvgPaths([]);
|
||||
setCurrentPath('');
|
||||
setSmoothingPoints([]);
|
||||
|
||||
if (onChange) {
|
||||
onChange('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signature-field">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`border border-gray-200 bg-white rounded touch-none ${
|
||||
readOnly ? 'cursor-default' : 'cursor-crosshair'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
opacity: disabled || readOnly ? 0.7 : 1,
|
||||
cursor: disabled
|
||||
? 'not-allowed'
|
||||
: readOnly
|
||||
? 'default'
|
||||
: 'crosshair',
|
||||
}}
|
||||
onMouseDown={readOnly ? undefined : startDrawing}
|
||||
onMouseMove={readOnly ? undefined : draw}
|
||||
onMouseUp={readOnly ? undefined : stopDrawing}
|
||||
onMouseLeave={readOnly ? undefined : stopDrawing}
|
||||
onTouchStart={readOnly ? undefined : startDrawing}
|
||||
onTouchMove={readOnly ? undefined : draw}
|
||||
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>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSignature}
|
||||
disabled={disabled || isEmpty}
|
||||
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-100 text-red-600 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{required && isEmpty && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
La signature est obligatoire
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureField;
|
||||
295
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
295
Front-End/src/components/Inscription/DynamicFormsList.js
Normal file
@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import FormRenderer from '@/components/Form/FormRenderer';
|
||||
import { CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
/**
|
||||
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
|
||||
* @param {Object} existingResponses - Réponses déjà sauvegardées
|
||||
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
|
||||
* @param {Boolean} enable - Si les formulaires sont modifiables
|
||||
*/
|
||||
export default function DynamicFormsList({
|
||||
schoolFileMasters,
|
||||
existingResponses = {},
|
||||
onFormSubmit,
|
||||
enable = true,
|
||||
onValidationChange,
|
||||
}) {
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
const [formsData, setFormsData] = useState({});
|
||||
const [formsValidation, setFormsValidation] = useState({});
|
||||
|
||||
// Initialiser les données avec les réponses existantes
|
||||
useEffect(() => {
|
||||
if (existingResponses && Object.keys(existingResponses).length > 0) {
|
||||
setFormsData(existingResponses);
|
||||
|
||||
// Marquer les formulaires avec réponses comme valides
|
||||
const validationState = {};
|
||||
Object.keys(existingResponses).forEach((formId) => {
|
||||
if (
|
||||
existingResponses[formId] &&
|
||||
Object.keys(existingResponses[formId]).length > 0
|
||||
) {
|
||||
validationState[formId] = true;
|
||||
}
|
||||
});
|
||||
setFormsValidation(validationState);
|
||||
}
|
||||
}, [existingResponses]);
|
||||
|
||||
// Debug: Log des formulaires maîtres reçus
|
||||
useEffect(() => {
|
||||
logger.debug(
|
||||
'DynamicFormsList - Formulaires maîtres reçus:',
|
||||
schoolFileMasters
|
||||
);
|
||||
}, [schoolFileMasters]);
|
||||
|
||||
// Mettre à jour la validation globale quand la validation des formulaires change
|
||||
useEffect(() => {
|
||||
const allFormsValid = schoolFileMasters.every(
|
||||
(master, index) => formsValidation[master.id] === true
|
||||
);
|
||||
|
||||
if (onValidationChange) {
|
||||
onValidationChange(allFormsValid);
|
||||
}
|
||||
}, [formsValidation, schoolFileMasters, onValidationChange]);
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire individuel
|
||||
*/
|
||||
const handleFormSubmit = async (formData, templateId) => {
|
||||
try {
|
||||
logger.debug('Soumission du formulaire:', { templateId, formData });
|
||||
|
||||
// Sauvegarder les données du formulaire
|
||||
setFormsData((prev) => ({
|
||||
...prev,
|
||||
[templateId]: formData,
|
||||
}));
|
||||
|
||||
// Marquer le formulaire comme complété
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: true,
|
||||
}));
|
||||
|
||||
// Appeler le callback parent
|
||||
if (onFormSubmit) {
|
||||
await onFormSubmit(formData, templateId);
|
||||
}
|
||||
|
||||
// Passer au formulaire suivant si disponible
|
||||
if (currentTemplateIndex < schoolFileMasters.length - 1) {
|
||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||
}
|
||||
|
||||
logger.debug('Formulaire soumis avec succès');
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la soumission du formulaire:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère les changements de validation d'un formulaire
|
||||
*/
|
||||
const handleFormValidationChange = (isValid, templateId) => {
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: isValid,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient l'icône de statut d'un formulaire
|
||||
*/
|
||||
const getFormStatusIcon = (templateId, isActive) => {
|
||||
if (isFormCompleted(templateId)) {
|
||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||
}
|
||||
if (isActive) {
|
||||
return <FileText className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
return <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtient le formulaire actuel à afficher
|
||||
*/
|
||||
const getCurrentTemplate = () => {
|
||||
return schoolFileMasters[currentTemplateIndex];
|
||||
};
|
||||
|
||||
if (!schoolFileMasters || schoolFileMasters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">Aucun formulaire à compléter</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentTemplate = getCurrentTemplate();
|
||||
|
||||
return (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des formulaires */}
|
||||
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Formulaires à compléter
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 mb-4">
|
||||
{
|
||||
Object.keys(formsValidation).filter((id) => formsValidation[id])
|
||||
.length
|
||||
}{' '}
|
||||
/ {schoolFileMasters.length} complétés
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{schoolFileMasters.map((master, index) => {
|
||||
const isActive = index === currentTemplateIndex;
|
||||
const isCompleted = isFormCompleted(master.id);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={master.id}
|
||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 font-semibold'
|
||||
: isCompleted
|
||||
? 'text-green-600 hover:bg-green-50'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(index)}
|
||||
>
|
||||
<span className="mr-3">
|
||||
{getFormStatusIcon(master.id, isActive)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">
|
||||
{master.formMasterData?.title ||
|
||||
master.title ||
|
||||
master.name ||
|
||||
'Formulaire sans nom'}
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<div className="text-xs text-green-600">
|
||||
Complété -{' '}
|
||||
{
|
||||
Object.keys(
|
||||
formsData[master.id] ||
|
||||
existingResponses[master.id] ||
|
||||
{}
|
||||
).length
|
||||
}{' '}
|
||||
réponse(s)
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
{master.formMasterData?.fields || master.fields
|
||||
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
||||
: 'À compléter'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Affichage du formulaire actuel */}
|
||||
<div className="w-3/4">
|
||||
{currentTemplate && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
{currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{currentTemplate.formMasterData?.description ||
|
||||
currentTemplate.description ||
|
||||
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Formulaire {currentTemplateIndex + 1} sur{' '}
|
||||
{schoolFileMasters.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vérifier si le formulaire maître a des données de configuration */}
|
||||
{(currentTemplate.formMasterData?.fields &&
|
||||
currentTemplate.formMasterData.fields.length > 0) ||
|
||||
(currentTemplate.fields && currentTemplate.fields.length > 0) ? (
|
||||
<FormRenderer
|
||||
key={currentTemplate.id}
|
||||
formConfig={{
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields:
|
||||
currentTemplate.formMasterData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[],
|
||||
submitLabel:
|
||||
currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||
}}
|
||||
onFormSubmit={(formData) =>
|
||||
handleFormSubmit(formData, currentTemplate.id)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600">
|
||||
Ce formulaire n'est pas encore configuré.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Contactez l'administration pour plus d'informations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileMasters.length && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-green-600 mb-2">
|
||||
Tous les formulaires ont été complétés !
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Vous pouvez maintenant passer à l'étape suivante.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,10 @@ import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import {
|
||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
saveFormResponses,
|
||||
fetchFormResponses,
|
||||
autoSaveRegisterForm,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
downloadTemplate,
|
||||
@ -21,7 +25,8 @@ import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||
import logger from '@/utils/logger';
|
||||
import FilesToUpload from '@/components/Inscription/FilesToUpload';
|
||||
import { DocusealForm } from '@docuseal/react';
|
||||
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
|
||||
import AutoSaveIndicator from '@/components/AutoSaveIndicator';
|
||||
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
|
||||
import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields';
|
||||
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
|
||||
@ -75,6 +80,8 @@ export default function InscriptionFormShared({
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
||||
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||
const [formResponses, setFormResponses] = useState({});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [isPage1Valid, setIsPage1Valid] = useState(false);
|
||||
@ -90,6 +97,9 @@ export default function InscriptionFormShared({
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState(null);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -121,18 +131,18 @@ export default function InscriptionFormShared({
|
||||
}, [schoolFileTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si tous les templates ont leur champ "file" différent de null
|
||||
const allSigned = schoolFileTemplates.every(
|
||||
(template) => template.file !== null
|
||||
);
|
||||
// Vérifier si tous les formulaires maîtres sont complétés
|
||||
const allCompleted =
|
||||
schoolFileMasters.length === 0 ||
|
||||
schoolFileMasters.every((master) => master.completed === true);
|
||||
|
||||
// Mettre à jour isPage4Valid en fonction de cette condition
|
||||
setIsPage5Valid(allSigned);
|
||||
// Mettre à jour isPage5Valid en fonction de cette condition
|
||||
setIsPage5Valid(allCompleted);
|
||||
|
||||
if (allSigned) {
|
||||
if (allCompleted) {
|
||||
setCurrentTemplateIndex(0);
|
||||
}
|
||||
}, [schoolFileTemplates]);
|
||||
}, [schoolFileMasters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si tous les documents avec is_required = true ont leur champ "file" différent de null
|
||||
@ -145,57 +155,216 @@ export default function InscriptionFormShared({
|
||||
logger.debug(allRequiredUploaded);
|
||||
}, [parentFileTemplates]);
|
||||
|
||||
const handleTemplateSigned = (index) => {
|
||||
const template = schoolFileTemplates[index];
|
||||
// Auto-sauvegarde périodique (toutes les 30 secondes)
|
||||
useEffect(() => {
|
||||
if (!enable || !autoSaveEnabled) return;
|
||||
|
||||
if (!template) {
|
||||
logger.error("Template introuvable pour l'index donné.");
|
||||
const interval = setInterval(() => {
|
||||
autoSave();
|
||||
}, 30000); // 30 secondes
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enable, autoSaveEnabled, formData, guardians, siblings]);
|
||||
|
||||
// Auto-sauvegarde quand les données changent (avec debounce)
|
||||
useEffect(() => {
|
||||
if (!enable || !autoSaveEnabled) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
autoSave();
|
||||
}, 2000); // Attendre 2 secondes après le dernier changement
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [formData, guardians, siblings]);
|
||||
|
||||
/**
|
||||
* Fonction d'auto-sauvegarde qui sauvegarde les données en cours
|
||||
*/
|
||||
const autoSave = async () => {
|
||||
if (!autoSaveEnabled || !studentId || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Télécharger le template
|
||||
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
|
||||
.then((downloadUrl) => fetch(downloadUrl))
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du téléchargement du fichier.');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const file = new File([blob], `${template.name}.pdf`, {
|
||||
type: blob.type,
|
||||
});
|
||||
|
||||
// Préparer les données pour la mise à jour
|
||||
const updateData = new FormData();
|
||||
updateData.append('file', file);
|
||||
|
||||
// Mettre à jour le template via l'API
|
||||
return editRegistrationSchoolFileTemplates(
|
||||
template.id,
|
||||
updateData,
|
||||
csrfToken
|
||||
);
|
||||
})
|
||||
.then((updatedTemplate) => {
|
||||
logger.debug('Template mis à jour avec succès :', updatedTemplate);
|
||||
|
||||
// Mettre à jour l'état local de schoolFileTemplates
|
||||
setSchoolFileTemplates((prevTemplates) => {
|
||||
const updatedTemplates = prevTemplates.map((t, i) =>
|
||||
i === index ? { ...t, file: updatedTemplate.data.file } : t
|
||||
);
|
||||
logger.debug(
|
||||
'État schoolFileTemplates mis à jour :',
|
||||
updatedTemplates
|
||||
);
|
||||
return updatedTemplates;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la mise à jour du template :', error);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
logger.debug('Auto-sauvegarde en cours...', {
|
||||
studentId,
|
||||
formDataKeys: Object.keys(formData),
|
||||
paymentFields: {
|
||||
registration_payment: formData.registration_payment,
|
||||
registration_payment_plan: formData.registration_payment_plan,
|
||||
tuition_payment: formData.tuition_payment,
|
||||
tuition_payment_plan: formData.tuition_payment_plan,
|
||||
},
|
||||
guardians: guardians.length,
|
||||
siblings: siblings.length,
|
||||
currentPage,
|
||||
});
|
||||
|
||||
// Fonction helper pour nettoyer les données avant sauvegarde
|
||||
const cleanDataForAutoSave = (data) => {
|
||||
const cleaned = {};
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key];
|
||||
// Garder seulement les valeurs non-vides et valides
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
// Pour les dates, vérifier le format
|
||||
if (key === 'birth_date' && value) {
|
||||
// Vérifier que la date est dans un format valide
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (dateRegex.test(value)) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
// Pour les codes postaux, vérifier que c'est un nombre
|
||||
else if (key === 'birth_postal_code' && value) {
|
||||
if (!isNaN(value) && value.toString().trim() !== '') {
|
||||
cleaned[key] = parseInt(value);
|
||||
}
|
||||
}
|
||||
// Pour les champs de paiement, toujours les inclure s'ils ont une valeur
|
||||
else if (key.includes('payment') && value) {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
// Pour les autres champs, garder la valeur si elle n'est pas vide
|
||||
else if (value.toString().trim() !== '') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
// Préparer les données à sauvegarder avec nettoyage
|
||||
const cleanedFormData = cleanDataForAutoSave(formData);
|
||||
|
||||
const dataToSave = {
|
||||
student: cleanedFormData,
|
||||
guardians: guardians.filter(
|
||||
(guardian) =>
|
||||
guardian &&
|
||||
(guardian.first_name || guardian.last_name || guardian.email)
|
||||
),
|
||||
siblings: siblings.filter(
|
||||
(sibling) => sibling && (sibling.first_name || sibling.last_name)
|
||||
),
|
||||
currentPage: currentPage,
|
||||
};
|
||||
|
||||
// Utiliser la fonction d'auto-save dédiée
|
||||
await autoSaveRegisterForm(studentId, dataToSave, csrfToken);
|
||||
|
||||
setLastSaved(new Date());
|
||||
logger.debug('Auto-sauvegarde réussie');
|
||||
} catch (error) {
|
||||
logger.error("Erreur lors de l'auto-sauvegarde:", error);
|
||||
// Ne pas afficher d'erreur à l'utilisateur pour l'auto-save
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère la sauvegarde à chaque changement d'étape
|
||||
*/
|
||||
const saveStepData = async () => {
|
||||
await autoSave();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire dynamique
|
||||
*/
|
||||
const handleDynamicFormSubmit = async (formData, templateId) => {
|
||||
try {
|
||||
logger.debug('Soumission du formulaire dynamique:', {
|
||||
templateId,
|
||||
formData,
|
||||
csrfToken: !!csrfToken,
|
||||
});
|
||||
|
||||
// Trouver le template correspondant pour récupérer sa configuration
|
||||
const currentTemplate = schoolFileMasters.find(
|
||||
(master) => master.id === templateId
|
||||
);
|
||||
if (!currentTemplate) {
|
||||
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||
}
|
||||
|
||||
// Construire la structure complète avec la configuration et les réponses
|
||||
const formTemplateData = {
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields: (
|
||||
currentTemplate.formMasterData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[]
|
||||
).map((field) => ({
|
||||
...field,
|
||||
// Ajouter la réponse de l'utilisateur selon le type de champ
|
||||
...(field.type === 'checkbox'
|
||||
? { checked: formData[field.id] || false }
|
||||
: {}),
|
||||
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
|
||||
...(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email'
|
||||
? { value: formData[field.id] || '' }
|
||||
: {}),
|
||||
})),
|
||||
submitLabel: currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||
responses: formData, // Garder aussi les réponses brutes pour facilité d'accès
|
||||
};
|
||||
|
||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||
logger.debug('Appel API saveFormResponses avec:', {
|
||||
templateId,
|
||||
formTemplateData,
|
||||
});
|
||||
const result = await saveFormResponses(
|
||||
templateId,
|
||||
formTemplateData,
|
||||
csrfToken
|
||||
);
|
||||
logger.debug("Réponse de l'API:", result);
|
||||
|
||||
// Mettre à jour l'état local des réponses
|
||||
setFormResponses((prev) => ({
|
||||
...prev,
|
||||
[templateId]: formData,
|
||||
}));
|
||||
|
||||
// Mettre à jour l'état local pour indiquer que le formulaire est complété
|
||||
setSchoolFileMasters((prevMasters) => {
|
||||
return prevMasters.map((master) =>
|
||||
master.id === templateId
|
||||
? { ...master, completed: true, responses: formData }
|
||||
: master
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug('Formulaire dynamique sauvegardé avec succès');
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la soumission du formulaire dynamique:', {
|
||||
templateId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
// Afficher l'erreur à l'utilisateur
|
||||
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère les changements de validation des formulaires dynamiques
|
||||
*/
|
||||
const handleDynamicFormsValidationChange = (isValid) => {
|
||||
setIsPage5Valid(isValid);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -223,6 +392,66 @@ export default function InscriptionFormShared({
|
||||
.catch((error) => logger.error('Error fetching profiles : ', error));
|
||||
|
||||
if (selectedEstablishmentId) {
|
||||
// Fetch data for school file masters
|
||||
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
|
||||
.then(async (data) => {
|
||||
logger.debug('School file masters fetched:', data);
|
||||
setSchoolFileMasters(data);
|
||||
|
||||
// Récupérer les données existantes de chaque template
|
||||
const responsesMap = {};
|
||||
for (const master of data) {
|
||||
if (master.id) {
|
||||
try {
|
||||
const templateData = await fetchFormResponses(master.id);
|
||||
if (templateData && templateData.formTemplateData) {
|
||||
// Si on a les réponses brutes sauvegardées, les utiliser
|
||||
if (templateData.formTemplateData.responses) {
|
||||
responsesMap[master.id] =
|
||||
templateData.formTemplateData.responses;
|
||||
} else {
|
||||
// Sinon, extraire les réponses depuis les champs
|
||||
const responses = {};
|
||||
if (templateData.formTemplateData.fields) {
|
||||
templateData.formTemplateData.fields.forEach((field) => {
|
||||
if (
|
||||
field.type === 'checkbox' &&
|
||||
field.checked !== undefined
|
||||
) {
|
||||
responses[field.id] = field.checked;
|
||||
} else if (
|
||||
field.type === 'radio' &&
|
||||
field.selected !== undefined
|
||||
) {
|
||||
responses[field.id] = field.selected;
|
||||
} else if (
|
||||
(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email') &&
|
||||
field.value !== undefined
|
||||
) {
|
||||
responses[field.id] = field.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
responsesMap[master.id] = responses;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Pas de données existantes pour le template ${master.id}:`,
|
||||
error
|
||||
);
|
||||
// Ce n'est pas critique si un template n'a pas de données
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormResponses(responsesMap);
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching school file masters:', error)
|
||||
);
|
||||
|
||||
// Fetch data for registration payment modes
|
||||
handleRegistrationPaymentModes();
|
||||
|
||||
@ -423,11 +652,16 @@ export default function InscriptionFormShared({
|
||||
onSubmit(formDataToSend);
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
const handleNextPage = async () => {
|
||||
// Sauvegarder avant de passer à l'étape suivante
|
||||
await saveStepData();
|
||||
setHasInteracted(false);
|
||||
setCurrentPage(currentPage + 1);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
const handlePreviousPage = async () => {
|
||||
// Sauvegarder avant de revenir à l'étape précédente
|
||||
await saveStepData();
|
||||
setCurrentPage(currentPage - 1);
|
||||
};
|
||||
|
||||
@ -479,7 +713,18 @@ export default function InscriptionFormShared({
|
||||
setStep={setCurrentPage}
|
||||
isStepValid={isStepValid}
|
||||
/>
|
||||
<div className="flex-1 h-full mt-12 ">
|
||||
|
||||
{/* Indicateur de sauvegarde automatique */}
|
||||
{enable && (
|
||||
<AutoSaveIndicator
|
||||
isSaving={isSaving}
|
||||
lastSaved={lastSaved}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
onToggleAutoSave={() => setAutoSaveEnabled(!autoSaveEnabled)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 h-full mt-6">
|
||||
{/* Page 1 : Informations sur l'élève */}
|
||||
{currentPage === 1 && (
|
||||
<StudentInfoForm
|
||||
@ -538,86 +783,15 @@ export default function InscriptionFormShared({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Page 5 : Section Fichiers d'inscription */}
|
||||
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||
{currentPage === 5 && (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des états de signature */}
|
||||
<div className="w-1/4 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Documents
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{schoolFileTemplates.map((template, index) => (
|
||||
<li
|
||||
key={template.id}
|
||||
className={`flex items-center cursor-pointer ${
|
||||
index === currentTemplateIndex
|
||||
? 'text-blue-600 font-bold'
|
||||
: template.file !== null
|
||||
? 'text-green-600'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(index)} // Mettre à jour l'index du template actuel
|
||||
>
|
||||
<span className="mr-2">
|
||||
{template.file !== null ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<Hourglass className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</span>
|
||||
{template.name || 'Document sans nom'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Affichage du fichier actuel */}
|
||||
<div className="w-3/4">
|
||||
{currentTemplateIndex < schoolFileTemplates.length && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{schoolFileTemplates[currentTemplateIndex].name ||
|
||||
'Document sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{schoolFileTemplates[currentTemplateIndex].description ||
|
||||
'Aucune description disponible pour ce document.'}
|
||||
</p>
|
||||
|
||||
{schoolFileTemplates[currentTemplateIndex].file === null ? (
|
||||
<DocusealForm
|
||||
key={schoolFileTemplates[currentTemplateIndex].slug}
|
||||
id="docusealForm"
|
||||
src={`https://docuseal.com/s/${schoolFileTemplates[currentTemplateIndex].slug}`}
|
||||
withDownloadButton={false}
|
||||
withTitle={false}
|
||||
onComplete={() =>
|
||||
handleTemplateSigned(currentTemplateIndex)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
|
||||
title="Document Viewer"
|
||||
className="w-full"
|
||||
style={{
|
||||
height: '75vh',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileTemplates.length && (
|
||||
<div className="text-center text-green-600 font-semibold">
|
||||
Tous les formulaires ont été signés avec succès !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DynamicFormsList
|
||||
schoolFileMasters={schoolFileMasters}
|
||||
existingResponses={formResponses}
|
||||
onFormSubmit={handleDynamicFormSubmit}
|
||||
onValidationChange={handleDynamicFormsValidationChange}
|
||||
enable={enable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dernière page : Section Fichiers parents */}
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Edit3, Trash2, FolderPlus, Signature, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
Trash2,
|
||||
FolderPlus,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import Table from '@/components/Table';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
import {
|
||||
// GET
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
fetchRegistrationSchoolFileTemplates,
|
||||
fetchRegistrationParentFileMasters,
|
||||
// POST
|
||||
createRegistrationFileGroup,
|
||||
@ -20,7 +27,7 @@ import {
|
||||
// DELETE
|
||||
deleteRegistrationFileGroup,
|
||||
deleteRegistrationSchoolFileMaster,
|
||||
deleteRegistrationParentFileMaster
|
||||
deleteRegistrationParentFileMaster,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
|
||||
import logger from '@/utils/logger';
|
||||
@ -34,10 +41,9 @@ import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal'
|
||||
|
||||
export default function FilesGroupsManagement({
|
||||
csrfToken,
|
||||
selectedEstablishmentId
|
||||
selectedEstablishmentId,
|
||||
}) {
|
||||
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||
const [parentFiles, setParentFileMasters] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [selectedGroup, setSelectedGroup] = useState(null);
|
||||
@ -46,7 +52,6 @@ export default function FilesGroupsManagement({
|
||||
const [fileToEdit, setFileToEdit] = useState(null);
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
const [groupToEdit, setGroupToEdit] = useState(null);
|
||||
const [reloadTemplates, setReloadTemplates] = useState(false);
|
||||
|
||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
@ -54,10 +59,6 @@ export default function FilesGroupsManagement({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const handleReloadTemplates = () => {
|
||||
setReloadTemplates(true);
|
||||
};
|
||||
|
||||
const transformFileData = (file, groups) => {
|
||||
const groupInfos = file.groups.map(
|
||||
(groupId) =>
|
||||
@ -77,79 +78,67 @@ export default function FilesGroupsManagement({
|
||||
Promise.all([
|
||||
fetchRegistrationSchoolFileMasters(selectedEstablishmentId),
|
||||
fetchRegistrationFileGroups(selectedEstablishmentId),
|
||||
fetchRegistrationSchoolFileTemplates(selectedEstablishmentId),
|
||||
fetchRegistrationParentFileMasters(selectedEstablishmentId),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
dataSchoolFileMasters,
|
||||
groupsData,
|
||||
dataSchoolFileTemplates,
|
||||
dataParentFileMasters,
|
||||
]) => {
|
||||
setGroups(groupsData);
|
||||
setSchoolFileTemplates(dataSchoolFileTemplates);
|
||||
setParentFileMasters(dataParentFileMasters);
|
||||
// Transformer chaque fichier pour inclure les informations complètes du groupe
|
||||
const transformedFiles = dataSchoolFileMasters.map((file) =>
|
||||
transformFileData(file, groupsData)
|
||||
);
|
||||
setSchoolFileMasters(transformedFiles);
|
||||
}
|
||||
)
|
||||
.then(([dataSchoolFileMasters, groupsData, dataParentFileMasters]) => {
|
||||
setGroups(groupsData);
|
||||
setParentFileMasters(dataParentFileMasters);
|
||||
// Transformer chaque fichier pour inclure les informations complètes du groupe
|
||||
const transformedFiles = dataSchoolFileMasters.map((file) =>
|
||||
transformFileData(file, groupsData)
|
||||
);
|
||||
setSchoolFileMasters(transformedFiles);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.debug(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setReloadTemplates(false);
|
||||
});
|
||||
}
|
||||
}, [reloadTemplates, selectedEstablishmentId]);
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
const deleteTemplateMaster = (templateMaster) => {
|
||||
setRemovePopupVisible(true);
|
||||
setRemovePopupMessage(
|
||||
`Attentions ! \nVous êtes sur le point de supprimer le document "${templateMaster.name}".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
|
||||
`Attention ! \nVous êtes sur le point de supprimer le formulaire "${templateMaster.name}".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
|
||||
);
|
||||
setRemovePopupOnConfirm(() => () => {
|
||||
setIsLoading(true);
|
||||
// Supprimer le template master de la base de données
|
||||
deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setSchoolFileMasters(
|
||||
schoolFileMasters.filter(
|
||||
(fichier) => fichier.id !== templateMaster.id
|
||||
)
|
||||
);
|
||||
showNotification(
|
||||
`Le document "${templateMaster.name}" a été correctement supprimé.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
|
||||
setRemovePopupVisible(false);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
// Supprimer le formulaire de la base de données
|
||||
deleteRegistrationSchoolFileMaster(templateMaster.id, csrfToken)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setSchoolFileMasters(
|
||||
schoolFileMasters.filter(
|
||||
(fichier) => fichier.id !== templateMaster.id
|
||||
)
|
||||
);
|
||||
showNotification(
|
||||
`Le formulaire "${templateMaster.name}" a été correctement supprimé.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
setRemovePopupVisible(false);
|
||||
} else {
|
||||
showNotification(
|
||||
`Erreur lors de la suppression du formulaire "${templateMaster.name}".`,
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
setRemovePopupVisible(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error deleting file from database:', error);
|
||||
showNotification(
|
||||
`Erreur lors de la suppression du document "${templateMaster.name}".`,
|
||||
`Erreur lors de la suppression du formulaire "${templateMaster.name}".`,
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
setRemovePopupVisible(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error deleting file from database:', error);
|
||||
showNotification(
|
||||
`Erreur lors de la suppression du document "${templateMaster.name}".`,
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
setRemovePopupVisible(false);
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -159,12 +148,11 @@ export default function FilesGroupsManagement({
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateTemplateMaster = ({ name, group_ids, id, is_required }) => {
|
||||
const handleCreateTemplateMaster = ({ name, group_ids, formMasterData }) => {
|
||||
const data = {
|
||||
name: name,
|
||||
id: id,
|
||||
groups: group_ids,
|
||||
is_required: is_required,
|
||||
formMasterData: formMasterData, // Envoyer directement l'objet
|
||||
};
|
||||
logger.debug(data);
|
||||
|
||||
@ -174,18 +162,32 @@ export default function FilesGroupsManagement({
|
||||
const transformedFile = transformFileData(data, groups);
|
||||
setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]);
|
||||
setIsModalOpen(false);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été créé avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error uploading file:', error);
|
||||
logger.error('Error creating form:', error);
|
||||
showNotification(
|
||||
'Erreur lors de la création du formulaire',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditTemplateMaster = ({ name, group_ids, id, is_required }) => {
|
||||
const handleEditTemplateMaster = ({
|
||||
name,
|
||||
group_ids,
|
||||
formMasterData,
|
||||
id,
|
||||
}) => {
|
||||
const data = {
|
||||
name: name,
|
||||
id: id,
|
||||
groups: group_ids,
|
||||
is_required: is_required,
|
||||
formMasterData: formMasterData,
|
||||
};
|
||||
logger.debug(data);
|
||||
|
||||
@ -197,11 +199,16 @@ export default function FilesGroupsManagement({
|
||||
prevFichiers.map((f) => (f.id === id ? transformedFile : f))
|
||||
);
|
||||
setIsModalOpen(false);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error editing file:', error);
|
||||
logger.error('Error editing form:', error);
|
||||
showNotification(
|
||||
'Erreur lors de la modification du fichier',
|
||||
'Erreur lors de la modification du formulaire',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
@ -383,24 +390,17 @@ export default function FilesGroupsManagement({
|
||||
name: 'Actions',
|
||||
transform: (row) => (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{row.file && (
|
||||
<a
|
||||
href={`${BASE_URL}${row.file}`}
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => editTemplateMaster(row)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
title="Modifier le formulaire"
|
||||
>
|
||||
<Edit3 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTemplateMaster(row)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
title="Supprimer le formulaire"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
@ -439,23 +439,26 @@ export default function FilesGroupsManagement({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Modal pour les fichiers */}
|
||||
{/* Modal pour les formulaires */}
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
setIsOpen={(isOpen) => {
|
||||
setIsModalOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setFileToEdit(null);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
title={isEditing ? 'Modification du document' : 'Ajouter un document'}
|
||||
modalClassName="w-4/5 h-4/5"
|
||||
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire'}
|
||||
modalClassName="w-11/12 h-5/6"
|
||||
>
|
||||
<FileUploadDocuSeal
|
||||
handleCreateTemplateMaster={handleCreateTemplateMaster}
|
||||
handleEditTemplateMaster={handleEditTemplateMaster}
|
||||
fileToEdit={fileToEdit}
|
||||
onSuccess={handleReloadTemplates}
|
||||
<FormTemplateBuilder
|
||||
onSave={
|
||||
isEditing ? handleEditTemplateMaster : handleCreateTemplateMaster
|
||||
}
|
||||
initialData={fileToEdit}
|
||||
groups={groups}
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -496,17 +499,18 @@ export default function FilesGroupsManagement({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section Fichiers */}
|
||||
{/* Section Formulaires */}
|
||||
<div className="mt-12 mb-4 w-3/5">
|
||||
<SectionHeader
|
||||
icon={Signature}
|
||||
title="Formulaires à remplir"
|
||||
description="Gérez les formulaires nécessitant une signature électronique."
|
||||
icon={FileText}
|
||||
title="Formulaires personnalisés"
|
||||
description="Créez et gérez vos formulaires d'inscription personnalisés."
|
||||
button={true}
|
||||
buttonOpeningModal={true}
|
||||
onClick={() => {
|
||||
setIsModalOpen(true);
|
||||
setIsEditing(false);
|
||||
setFileToEdit(null);
|
||||
}}
|
||||
/>
|
||||
<Table
|
||||
@ -516,7 +520,7 @@ export default function FilesGroupsManagement({
|
||||
<AlertMessage
|
||||
type="warning"
|
||||
title="Aucun formulaire enregistré"
|
||||
message="Veuillez procéder à la création d'un nouveau formulaire à signer"
|
||||
message="Veuillez procéder à la création d'un nouveau formulaire d'inscription"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user