mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 15:33:22 +00:00
feat: Ajout FormTemplateBuilder [N3WTS-17]
This commit is contained in:
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import Button from '@/components/Form/Button';
|
||||
import Logo from '@/components/Logo'; // Import du composant Logo
|
||||
import FormRenderer from '@/components/Form/FormRenderer';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('homePage');
|
||||
@ -14,7 +15,7 @@ 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" />
|
||||
<FormRenderer />
|
||||
<FormTemplateBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
354
Front-End/src/components/Form/AddFieldModal.js
Normal file
354
Front-End/src/components/Form/AddFieldModal.js
Normal file
@ -0,0 +1,354 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import SelectChoice from './SelectChoice';
|
||||
import Button from './Button';
|
||||
import IconSelector from './IconSelector';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { FIELD_TYPES } from './FormTypes';
|
||||
|
||||
export default function AddFieldModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
editingField = null,
|
||||
editingIndex = -1,
|
||||
}) {
|
||||
const isEditing = editingIndex >= 0;
|
||||
|
||||
const [currentField, setCurrentField] = useState(
|
||||
editingField || {
|
||||
id: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
icon: '',
|
||||
options: [],
|
||||
text: '',
|
||||
placeholder: '',
|
||||
}
|
||||
);
|
||||
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [newOption, setNewOption] = useState('');
|
||||
|
||||
const { control, handleSubmit, reset } = useForm();
|
||||
|
||||
// Ajouter une option au select
|
||||
const addOption = () => {
|
||||
if (newOption.trim()) {
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
options: [...currentField.options, newOption.trim()],
|
||||
});
|
||||
setNewOption('');
|
||||
}
|
||||
};
|
||||
|
||||
// Supprimer une option du select
|
||||
const removeOption = (index) => {
|
||||
const newOptions = currentField.options.filter((_, i) => i !== index);
|
||||
setCurrentField({ ...currentField, options: newOptions });
|
||||
};
|
||||
|
||||
// Sélectionner une icône
|
||||
const selectIcon = (iconName) => {
|
||||
setCurrentField({ ...currentField, icon: iconName });
|
||||
|
||||
// Mettre à jour la valeur dans le formulaire
|
||||
const iconField = control._fields.icon;
|
||||
if (iconField && iconField.onChange) {
|
||||
iconField.onChange(iconName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (data) => {
|
||||
onSubmit(data, currentField, editingIndex);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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-xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{isEditing ? 'Modifier le champ' : 'Ajouter un champ'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(handleFieldSubmit)} className="space-y-4">
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
defaultValue={currentField.type}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<SelectChoice
|
||||
label="Type de champ"
|
||||
name="type"
|
||||
selected={value}
|
||||
callback={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
type: e.target.value,
|
||||
});
|
||||
}}
|
||||
choices={FIELD_TYPES}
|
||||
placeHolder="Sélectionner un type"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{![
|
||||
'paragraph',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'heading5',
|
||||
'heading6',
|
||||
].includes(currentField.type) && (
|
||||
<>
|
||||
<Controller
|
||||
name="label"
|
||||
control={control}
|
||||
defaultValue={currentField.label}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputTextIcon
|
||||
label="Label du champ"
|
||||
name="label"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
label: e.target.value,
|
||||
});
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="placeholder"
|
||||
control={control}
|
||||
defaultValue={currentField.placeholder}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputTextIcon
|
||||
label="Placeholder (optionnel)"
|
||||
name="placeholder"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
placeholder: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name="required"
|
||||
control={control}
|
||||
defaultValue={currentField.required}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.checked);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
required: e.target.checked,
|
||||
});
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label htmlFor="required">Champ obligatoire</label>
|
||||
</div>
|
||||
|
||||
{(currentField.type === 'text' ||
|
||||
currentField.type === 'email' ||
|
||||
currentField.type === 'date') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Icône (optionnel)
|
||||
</label>
|
||||
<Controller
|
||||
name="icon"
|
||||
control={control}
|
||||
defaultValue={currentField.icon}
|
||||
render={({ field: { onChange } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 p-3 border border-gray-300 rounded-md bg-gray-50">
|
||||
{currentField.icon &&
|
||||
LucideIcons[currentField.icon] ? (
|
||||
<>
|
||||
{React.createElement(
|
||||
LucideIcons[currentField.icon],
|
||||
{
|
||||
size: 20,
|
||||
className: 'text-gray-600',
|
||||
}
|
||||
)}
|
||||
<span className="text-sm text-gray-700">
|
||||
{currentField.icon}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">
|
||||
Aucune icône sélectionnée
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
text="Choisir"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowIconPicker(true);
|
||||
}}
|
||||
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
/>
|
||||
{currentField.icon && (
|
||||
<Button
|
||||
type="button"
|
||||
text="✕"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
setCurrentField({ ...currentField, icon: '' });
|
||||
}}
|
||||
className="px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{[
|
||||
'paragraph',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'heading4',
|
||||
'heading5',
|
||||
'heading6',
|
||||
].includes(currentField.type) && (
|
||||
<Controller
|
||||
name="text"
|
||||
control={control}
|
||||
defaultValue={currentField.text}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{currentField.type === 'paragraph'
|
||||
? 'Texte du paragraphe'
|
||||
: 'Texte du titre'}
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCurrentField({
|
||||
...currentField,
|
||||
text: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentField.type === 'select' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Options de la liste
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOption}
|
||||
onChange={(e) => setNewOption(e.target.value)}
|
||||
placeholder="Nouvelle option"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onKeyPress={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), addOption())
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text="Ajouter"
|
||||
onClick={addOption}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
text={isEditing ? 'Modifier' : 'Ajouter'}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text="Annuler"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sélecteur d'icônes - déplacé en dehors du formulaire */}
|
||||
<IconSelector
|
||||
isOpen={showIconPicker}
|
||||
onClose={() => setShowIconPicker(false)}
|
||||
onSelect={selectIcon}
|
||||
selectedIcon={currentField.icon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -101,8 +101,30 @@ export default function FormRenderer({
|
||||
</h2>
|
||||
|
||||
{formConfig.fields.map((field) => (
|
||||
<div key={field.id} className="flex flex-col mt-4">
|
||||
{field.type === 'paragraph' && <p>{field.text}</p>}
|
||||
<div
|
||||
key={field.id || `field-${Math.random().toString(36).substr(2, 9)}`}
|
||||
className="flex flex-col mt-4"
|
||||
>
|
||||
{field.type === 'heading1' && (
|
||||
<h1 className="text-3xl font-bold mb-3">{field.text}</h1>
|
||||
)}
|
||||
{field.type === 'heading2' && (
|
||||
<h2 className="text-2xl font-bold mb-3">{field.text}</h2>
|
||||
)}
|
||||
{field.type === 'heading3' && (
|
||||
<h3 className="text-xl font-bold mb-2">{field.text}</h3>
|
||||
)}
|
||||
{field.type === 'heading4' && (
|
||||
<h4 className="text-lg font-bold mb-2">{field.text}</h4>
|
||||
)}
|
||||
{field.type === 'heading5' && (
|
||||
<h5 className="text-base font-bold mb-1">{field.text}</h5>
|
||||
)}
|
||||
{field.type === 'heading6' && (
|
||||
<h6 className="text-sm font-bold mb-1">{field.text}</h6>
|
||||
)}
|
||||
|
||||
{field.type === 'paragraph' && <p className="mb-4">{field.text}</p>}
|
||||
|
||||
{(field.type === 'text' ||
|
||||
field.type === 'email' ||
|
||||
|
||||
596
Front-End/src/components/Form/FormTemplateBuilder.js
Normal file
596
Front-End/src/components/Form/FormTemplateBuilder.js
Normal file
@ -0,0 +1,596 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import FormRenderer from './FormRenderer';
|
||||
import AddFieldModal from './AddFieldModal';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import {
|
||||
Edit2,
|
||||
Trash2,
|
||||
PlusCircle,
|
||||
Download,
|
||||
Upload,
|
||||
GripVertical,
|
||||
TextCursorInput,
|
||||
AtSign,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Type,
|
||||
AlignLeft,
|
||||
Save,
|
||||
ChevronUp,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Code,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
const FIELD_TYPES_ICON = {
|
||||
text: { icon: TextCursorInput },
|
||||
email: { icon: AtSign },
|
||||
date: { icon: Calendar },
|
||||
select: { icon: ChevronDown },
|
||||
textarea: { icon: Type },
|
||||
paragraph: { icon: AlignLeft },
|
||||
heading1: { icon: Heading1 },
|
||||
heading2: { icon: Heading2 },
|
||||
heading3: { icon: Heading3 },
|
||||
heading4: { icon: Heading4 },
|
||||
heading5: { icon: Heading5 },
|
||||
heading6: { icon: Heading6 },
|
||||
};
|
||||
|
||||
// Type d'item pour le drag and drop
|
||||
const ItemTypes = {
|
||||
FIELD: 'field',
|
||||
};
|
||||
|
||||
// Composant pour un champ draggable
|
||||
const DraggableFieldItem = ({
|
||||
field,
|
||||
index,
|
||||
moveField,
|
||||
editField,
|
||||
deleteField,
|
||||
}) => {
|
||||
const ref = React.useRef(null);
|
||||
|
||||
// Configuration du drag (ce qu'on peut déplacer)
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.FIELD,
|
||||
item: { index },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Configuration du drop (où on peut déposer)
|
||||
const [, drop] = useDrop({
|
||||
accept: ItemTypes.FIELD,
|
||||
hover: (item, monitor) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Ne rien faire si on survole le même élément
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Déterminer la position de la souris par rapport à l'élément survolé
|
||||
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
// Ne pas remplacer si on n'a pas dépassé la moitié de l'élément
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Effectuer le déplacement
|
||||
moveField(dragIndex, hoverIndex);
|
||||
|
||||
// Mettre à jour l'index de l'élément déplacé
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
// Combiner drag et drop sur le même élément de référence
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-200 ${
|
||||
isDragging ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
||||
<GripVertical size={18} />
|
||||
</div>
|
||||
{FIELD_TYPES_ICON[field.type] &&
|
||||
React.createElement(FIELD_TYPES_ICON[field.type].icon, {
|
||||
size: 18,
|
||||
className: 'text-gray-600',
|
||||
})}
|
||||
<span className="font-medium">
|
||||
{field.type === 'paragraph'
|
||||
? 'Paragraphe'
|
||||
: field.type.startsWith('heading')
|
||||
? `Titre ${field.type.replace('heading', '')}`
|
||||
: field.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
({field.type}){field.required && ' *'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => editField(index)}
|
||||
className="p-1 text-blue-500 hover:text-blue-700"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteField(index)}
|
||||
className="p-1 text-red-500 hover:text-red-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function FormTemplateBuilder() {
|
||||
const [formConfig, setFormConfig] = useState({
|
||||
id: 0,
|
||||
title: 'Nouveau formulaire',
|
||||
submitLabel: 'Envoyer',
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState(-1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState({ type: '', text: '' });
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [showJsonSection, setShowJsonSection] = useState(false);
|
||||
|
||||
const { reset: resetField } = useForm();
|
||||
|
||||
// Gérer l'affichage du bouton de défilement
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Afficher le bouton quand on descend d'au moins 300px
|
||||
setShowScrollButton(window.scrollY > 300);
|
||||
};
|
||||
|
||||
// Ajouter l'écouteur d'événement
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
// Nettoyage de l'écouteur lors du démontage du composant
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fonction pour remonter en haut de la page
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
// Générer un ID unique pour les champs
|
||||
const generateFieldId = (label) => {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/[àáâãäå]/g, 'a')
|
||||
.replace(/[èéêë]/g, 'e')
|
||||
.replace(/[ìíîï]/g, 'i')
|
||||
.replace(/[òóôõö]/g, 'o')
|
||||
.replace(/[ùúûü]/g, 'u')
|
||||
.replace(/[ç]/g, 'c')
|
||||
.replace(/[^a-z0-9]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '');
|
||||
};
|
||||
|
||||
// Ajouter ou modifier un champ
|
||||
const handleFieldSubmit = (data, currentField, editIndex) => {
|
||||
const isHeadingType = data.type.startsWith('heading');
|
||||
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
|
||||
|
||||
const fieldData = {
|
||||
...data,
|
||||
id: isContentTypeOnly
|
||||
? undefined
|
||||
: generateFieldId(data.label || 'field'),
|
||||
options: data.type === 'select' ? currentField.options : undefined,
|
||||
icon: data.icon || currentField.icon || undefined,
|
||||
placeholder: data.placeholder || undefined,
|
||||
text: isContentTypeOnly ? data.text : undefined,
|
||||
};
|
||||
|
||||
// Nettoyer les propriétés undefined
|
||||
Object.keys(fieldData).forEach((key) => {
|
||||
if (fieldData[key] === undefined || fieldData[key] === '') {
|
||||
delete fieldData[key];
|
||||
}
|
||||
});
|
||||
|
||||
const newFields = [...formConfig.fields];
|
||||
|
||||
if (editIndex >= 0) {
|
||||
newFields[editIndex] = fieldData;
|
||||
} else {
|
||||
newFields.push(fieldData);
|
||||
}
|
||||
|
||||
setFormConfig({ ...formConfig, fields: newFields });
|
||||
setEditingIndex(-1);
|
||||
};
|
||||
|
||||
// Modifier un champ existant
|
||||
const editField = (index) => {
|
||||
setEditingIndex(index);
|
||||
setShowAddFieldModal(true);
|
||||
};
|
||||
|
||||
// Supprimer un champ
|
||||
const deleteField = (index) => {
|
||||
const newFields = formConfig.fields.filter((_, i) => i !== index);
|
||||
setFormConfig({ ...formConfig, fields: newFields });
|
||||
};
|
||||
|
||||
// Déplacer un champ
|
||||
const moveField = (dragIndex, hoverIndex) => {
|
||||
const newFields = [...formConfig.fields];
|
||||
const draggedField = newFields[dragIndex];
|
||||
|
||||
// Supprimer l'élément déplacé
|
||||
newFields.splice(dragIndex, 1);
|
||||
|
||||
// Insérer l'élément à sa nouvelle position
|
||||
newFields.splice(hoverIndex, 0, draggedField);
|
||||
|
||||
setFormConfig({ ...formConfig, fields: newFields });
|
||||
};
|
||||
|
||||
// Exporter le JSON
|
||||
const exportJson = () => {
|
||||
const jsonString = JSON.stringify(formConfig, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `formulaire_${formConfig.title.replace(/\s+/g, '_').toLowerCase()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Importer un JSON
|
||||
const importJson = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const imported = JSON.parse(e.target.result);
|
||||
setFormConfig(imported);
|
||||
} catch (error) {
|
||||
alert('Erreur lors de l'importation du fichier JSON');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Sauvegarder le formulaire (pour le backend)
|
||||
const saveFormTemplate = async () => {
|
||||
// Validation basique
|
||||
if (!formConfig.title.trim()) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: 'Le titre du formulaire est requis',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (formConfig.fields.length === 0) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: 'Ajoutez au moins un champ au formulaire',
|
||||
});
|
||||
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),
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error('Erreur lors de l\'enregistrement du formulaire');
|
||||
// }
|
||||
|
||||
// const data = await response.json();
|
||||
|
||||
// Simulation d'une réponse du backend
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
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',
|
||||
text:
|
||||
error.message || "Une erreur est survenue lors de l'enregistrement",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* Panel de configuration */}
|
||||
<div
|
||||
className={
|
||||
showJsonSection
|
||||
? 'lg:col-span-3 space-y-6'
|
||||
: 'lg:col-span-5 space-y-6'
|
||||
}
|
||||
>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
Configuration du formulaire
|
||||
</h2>
|
||||
|
||||
{/* Configuration générale */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold mr-4">
|
||||
Paramètres généraux
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowJsonSection(!showJsonSection)}
|
||||
className="px-4 py-2 rounded-md inline-flex items-center gap-2 bg-gray-500 hover:bg-gray-600 text-white"
|
||||
title={
|
||||
showJsonSection ? 'Masquer le JSON' : 'Afficher le JSON'
|
||||
}
|
||||
>
|
||||
{showJsonSection ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
<span>
|
||||
{showJsonSection ? 'Masquer JSON' : 'Afficher JSON'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={saveFormTemplate}
|
||||
disabled={saving}
|
||||
className={`px-4 py-2 rounded-md inline-flex items-center gap-2 ${
|
||||
saving
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
}`}
|
||||
title="Enregistrer le formulaire"
|
||||
>
|
||||
<Save size={18} />
|
||||
<span>
|
||||
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMessage.text && (
|
||||
<div
|
||||
className={`p-3 rounded ${
|
||||
saveMessage.type === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputTextIcon
|
||||
label="Titre du formulaire"
|
||||
name="title"
|
||||
value={formConfig.title}
|
||||
onChange={(e) =>
|
||||
setFormConfig({ ...formConfig, title: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<InputTextIcon
|
||||
label="Texte du bouton de soumission du formulaire"
|
||||
name="submitLabel"
|
||||
value={formConfig.submitLabel}
|
||||
onChange={(e) =>
|
||||
setFormConfig({
|
||||
...formConfig,
|
||||
submitLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Liste des champs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold mr-4">
|
||||
Champs du formulaire ({formConfig.fields.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(true);
|
||||
}}
|
||||
className="p-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
title="Ajouter un champ"
|
||||
>
|
||||
<PlusCircle size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formConfig.fields.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<p className="text-gray-500 italic mb-4">
|
||||
Aucun champ ajouté
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIndex(-1);
|
||||
setShowAddFieldModal(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"
|
||||
>
|
||||
<PlusCircle size={18} />
|
||||
<span>Ajouter mon premier champ</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{formConfig.fields.map((field, index) => (
|
||||
<DraggableFieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
index={index}
|
||||
moveField={moveField}
|
||||
editField={editField}
|
||||
deleteField={deleteField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6">
|
||||
{/* Les actions ont été déplacées dans la section JSON généré */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON généré */}
|
||||
{showJsonSection && (
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white p-6 rounded-lg shadow h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold mr-4">JSON généré</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={exportJson}
|
||||
className="p-2 bg-purple-500 text-white rounded-md hover:bg-purple-600 transition-colors"
|
||||
title="Exporter JSON"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<label
|
||||
className="p-2 bg-orange-500 text-white rounded-md hover:bg-orange-600 cursor-pointer transition-colors"
|
||||
title="Importer JSON"
|
||||
>
|
||||
<Upload size={18} />
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={importJson}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-gray-100 p-4 rounded text-sm overflow-auto max-h-96">
|
||||
{JSON.stringify(formConfig, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aperçu */}
|
||||
<div className="mt-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
|
||||
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
|
||||
{formConfig.fields.length > 0 ? (
|
||||
<FormRenderer formConfig={formConfig} />
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-center">
|
||||
Ajoutez des champs pour voir l'aperçu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal d'ajout/modification de champ */}
|
||||
<AddFieldModal
|
||||
isOpen={showAddFieldModal}
|
||||
onClose={() => setShowAddFieldModal(false)}
|
||||
onSubmit={handleFieldSubmit}
|
||||
editingField={
|
||||
editingIndex >= 0 ? formConfig.fields[editingIndex] : null
|
||||
}
|
||||
editingIndex={editingIndex}
|
||||
/>
|
||||
|
||||
{/* Bouton flottant pour remonter en haut */}
|
||||
{showScrollButton && (
|
||||
<div className="fixed bottom-6 right-6 z-10">
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="p-4 rounded-full shadow-lg flex items-center justify-center bg-gray-500 hover:bg-gray-600 text-white transition-all duration-300"
|
||||
title="Remonter en haut de la page"
|
||||
>
|
||||
<ChevronUp size={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
14
Front-End/src/components/Form/FormTypes.js
Normal file
14
Front-End/src/components/Form/FormTypes.js
Normal file
@ -0,0 +1,14 @@
|
||||
export const FIELD_TYPES = [
|
||||
{ value: 'text', label: 'Texte' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'select', label: 'Liste déroulante' },
|
||||
{ value: 'textarea', label: 'Zone de texte riche' },
|
||||
{ value: 'paragraph', label: 'Paragraphe' },
|
||||
{ value: 'heading1', label: 'Titre 1' },
|
||||
{ value: 'heading2', label: 'Titre 2' },
|
||||
{ value: 'heading3', label: 'Titre 3' },
|
||||
{ value: 'heading4', label: 'Titre 4' },
|
||||
{ value: 'heading5', label: 'Titre 5' },
|
||||
{ value: 'heading6', label: 'Titre 6' },
|
||||
];
|
||||
145
Front-End/src/components/Form/IconSelector.js
Normal file
145
Front-End/src/components/Form/IconSelector.js
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import Button from './Button';
|
||||
|
||||
export default function IconSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
selectedIcon = '',
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const excludedKeys = new Set([
|
||||
'Icon',
|
||||
'DynamicIcon',
|
||||
'createLucideIcon',
|
||||
'default',
|
||||
'icons',
|
||||
]);
|
||||
|
||||
const allIcons = Object.keys(LucideIcons).filter((key) => {
|
||||
// Exclure les utilitaires
|
||||
if (excludedKeys.has(key)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!searchTerm) return allIcons;
|
||||
return allIcons.filter((iconName) =>
|
||||
iconName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [searchTerm, allIcons]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const selectIcon = (iconName) => {
|
||||
onSelect(iconName);
|
||||
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 une icône ({filteredIcons.length} / {allIcons.length}{' '}
|
||||
icônes)
|
||||
</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 une icône..."
|
||||
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 xl:grid-cols-6 gap-4">
|
||||
{filteredIcons.map((iconName) => {
|
||||
try {
|
||||
const IconComponent = LucideIcons[iconName];
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
onClick={() => selectIcon(iconName)}
|
||||
className={`
|
||||
p-5 rounded-lg border-2 transition-all duration-200
|
||||
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
|
||||
${
|
||||
selectedIcon === iconName
|
||||
? 'bg-blue-100 border-blue-500 shadow-md scale-105'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}
|
||||
`}
|
||||
title={iconName}
|
||||
>
|
||||
<IconComponent
|
||||
size={32}
|
||||
className="text-gray-700 flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 text-center leading-tight break-words px-1 overflow-hidden max-w-full">
|
||||
{iconName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
} catch (error) {
|
||||
// En cas d'erreur avec une icône spécifique, ne pas la rendre
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
{searchTerm ? (
|
||||
<>
|
||||
{filteredIcons.length} icône(s) trouvée(s) sur {allIcons.length}{' '}
|
||||
disponibles
|
||||
</>
|
||||
) : (
|
||||
<>Total : {allIcons.length} icônes disponibles</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
text="Aucune icône"
|
||||
onClick={() => selectIcon('')}
|
||||
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600"
|
||||
/>
|
||||
<Button
|
||||
text="Annuler"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
docs/manuels/installation/premier-pas.md
Normal file
94
docs/manuels/installation/premier-pas.md
Normal file
@ -0,0 +1,94 @@
|
||||
# 🧭 Premiers Pas avec N3WT-SCHOOL
|
||||
|
||||
Bienvenue dans **N3WT-SCHOOL** !
|
||||
Ce guide rapide vous accompagnera dans les premières étapes de configuration de votre instance afin de la rendre pleinement opérationnelle pour votre établissement.
|
||||
|
||||
## ✅ Étapes à suivre :
|
||||
|
||||
1. **Configurer la signature électronique des documents via Docuseal**
|
||||
2. **Activer l'envoi d'e-mails depuis la plateforme**
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 1. Configuration de la signature électronique (Docuseal)
|
||||
|
||||
Afin de permettre la signature électronique des documents administratifs (inscriptions, conventions, etc.), N3WT-SCHOOL s'appuie sur [**Docuseal**](https://docuseal.com), un service sécurisé de signature électronique.
|
||||
|
||||
### Étapes :
|
||||
|
||||
1. Connectez-vous ou créez un compte sur Docuseal :
|
||||
👉 [https://docuseal.com/sign_in](https://docuseal.com/sign_in)
|
||||
|
||||
2. Une fois connecté, accédez à la section API :
|
||||
👉 [https://console.docuseal.com/api](https://console.docuseal.com/api)
|
||||
|
||||
3. Copiez votre **X-Auth-Token** personnel.
|
||||
Ce jeton permettra à N3WT-SCHOOL de se connecter à votre compte Docuseal.
|
||||
|
||||
4. **Envoyez votre X-Auth-Token à l'équipe N3WT-SCHOOL** pour qu'un administrateur puisse finaliser la configuration :
|
||||
✉️ Contact : [contact@n3wtschool.com](mailto:contact@n3wtschool.com)
|
||||
|
||||
> ⚠️ Cette opération doit impérativement être réalisée par un administrateur N3WT-SCHOOL.
|
||||
> Ne partagez pas ce token en dehors de ce cadre.
|
||||
|
||||
---
|
||||
|
||||
## 📧 2. Configuration de l'envoi d’e-mails
|
||||
|
||||
L’envoi de mails depuis N3WT-SCHOOL est requis pour :
|
||||
|
||||
- Notifications aux étudiants
|
||||
- Accusés de réception
|
||||
- Envoi de documents (factures, conventions…)
|
||||
|
||||
Vous devrez renseigner les informations de votre fournisseur SMTP dans **Paramètres > E-mail** de l’application.
|
||||
|
||||
### Informations requises :
|
||||
|
||||
- Hôte SMTP
|
||||
- Port SMTP
|
||||
- Type de sécurité (TLS / SSL)
|
||||
- Adresse e-mail (utilisateur SMTP)
|
||||
- Mot de passe ou **mot de passe applicatif**
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Mot de passe applicatif (Gmail, Outlook, etc.)
|
||||
|
||||
Certains fournisseurs (notamment **Gmail**, **Yahoo**, **iCloud**) ne permettent pas d’utiliser directement votre mot de passe personnel pour des applications tierces.
|
||||
Vous devez créer un **mot de passe applicatif**.
|
||||
|
||||
### Exemple : Créer un mot de passe applicatif avec Gmail
|
||||
|
||||
1. Connectez-vous à [votre compte Google](https://myaccount.google.com)
|
||||
2. Allez dans **Sécurité > Validation en 2 étapes**
|
||||
3. Activez la validation en 2 étapes si ce n’est pas déjà fait
|
||||
4. Ensuite, allez dans **Mots de passe des applications**
|
||||
5. Sélectionnez une application (ex. : "Autre (personnalisée)") et nommez-la "N3WT-SCHOOL"
|
||||
6. Copiez le mot de passe généré et utilisez-le comme **mot de passe SMTP**
|
||||
|
||||
> 📎 Vous pouvez consulter l’aide officielle de Google ici :
|
||||
> [Créer un mot de passe d’application – Google](https://support.google.com/accounts/answer/185833)
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Configuration SMTP — Fournisseurs courants
|
||||
|
||||
| Fournisseur | SMTP Host | Port TLS | Port SSL | Sécurité | Lien aide SMTP |
|
||||
| ----------------- | ------------------- | -------- | -------- | -------- | ---------------------------------------------------------------------------- |
|
||||
| Gmail | smtp.gmail.com | 587 | 465 | TLS/SSL | [Aide SMTP Gmail](https://support.google.com/mail/answer/7126229?hl=fr) |
|
||||
| Outlook / Hotmail | smtp.office365.com | 587 | — | TLS | [Aide SMTP Outlook](https://support.microsoft.com/fr-fr/office) |
|
||||
| Yahoo Mail | smtp.mail.yahoo.com | 587 | 465 | TLS/SSL | [Aide SMTP Yahoo](https://help.yahoo.com/kb/SLN4724.html) |
|
||||
| iCloud Mail | smtp.mail.me.com | 587 | 465 | TLS/SSL | [Aide iCloud SMTP](https://support.apple.com/fr-fr/HT202304) |
|
||||
| OVH | ssl0.ovh.net | 587 | 465 | TLS/SSL | [Aide OVH SMTP](https://help.ovhcloud.com/csm/fr-email-general-settings) |
|
||||
| Infomaniak | mail.infomaniak.com | 587 | 465 | TLS/SSL | [Aide SMTP Infomaniak](https://www.infomaniak.com/fr/support/faq/1817) |
|
||||
| Gandi | mail.gandi.net | 587 | 465 | TLS/SSL | [Aide SMTP Gandi](https://docs.gandi.net/fr/mail/faq/envoyer_des_mails.html) |
|
||||
|
||||
> 📝 Si votre fournisseur ne figure pas dans cette liste, n'hésitez pas à contacter votre fournisseur de mail pour obtenir ces informations.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Vous êtes prêt·e !
|
||||
|
||||
Une fois ces deux configurations effectuées, votre instance N3WT-SCHOOL est prête à fonctionner pleinement.
|
||||
Vous pourrez ensuite ajouter vos formations, étudiants, documents et automatiser toute votre gestion scolaire.
|
||||
Reference in New Issue
Block a user