1 Commits

Author SHA1 Message Date
5f0866fd15 feat: Création de tests unitaires pour le back[#63] 2025-06-05 16:42:50 +02:00
66 changed files with 448 additions and 2100 deletions

12
Back-End/Auth/tests.py Normal file
View File

@ -0,0 +1,12 @@
from django.test import TestCase
from .models import Profile
class ProfileModelTest(TestCase):
def test_create_profile(self):
user = Profile.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123"
)
self.assertEqual(user.email, "test@example.com")
self.assertTrue(user.check_password("testpass123"))

View File

@ -1,3 +1,11 @@
from django.test import TestCase
from .models import Domain
# Create your tests here.
class DomainModelTest(TestCase):
def test_create_domain(self):
domain = Domain.objects.create(name="Mathématiques", cycle=1)
self.assertEqual(domain.name, "Mathématiques")
self.assertEqual(domain.cycle, 1)
self.assertIsNotNone(domain.id)

View File

@ -0,0 +1,17 @@
from django.test import TestCase
from .models import Establishment, StructureType, EvaluationFrequency
class EstablishmentModelTest(TestCase):
def test_create_establishment(self):
est = Establishment.objects.create(
name="École Test",
address="1 rue de l'École",
total_capacity=100,
establishment_type=[StructureType.PRIMAIRE],
evaluation_frequency=EvaluationFrequency.TRIMESTER,
licence_code="ABC123"
)
self.assertEqual(est.name, "École Test")
self.assertEqual(est.establishment_type, [StructureType.PRIMAIRE])
self.assertTrue(est.is_active)
self.assertIsNotNone(est.created_at)

View File

@ -0,0 +1,13 @@
from django.test import TestCase
from .models import Conversation
class ConversationModelTest(TestCase):
def test_create_conversation(self):
conv = Conversation.objects.create(
name="Groupe Test",
conversation_type="group"
)
self.assertEqual(conv.name, "Groupe Test")
self.assertEqual(conv.conversation_type, "group")
self.assertTrue(conv.is_active)
self.assertIsNotNone(conv.id)

View File

@ -0,0 +1,22 @@
from django.test import TestCase
from .models import Notification, TypeNotif
from Auth.models import Profile
class NotificationModelTest(TestCase):
def setUp(self):
self.user = Profile.objects.create_user(
username="notifuser",
email="notif@example.com",
password="testpass123"
)
def test_create_notification(self):
notif = Notification.objects.create(
user=self.user,
message="Un message a été reçu",
typeNotification=TypeNotif.NOTIF_MESSAGE
)
self.assertEqual(notif.user, self.user)
self.assertEqual(notif.message, "Un message a été reçu")
self.assertFalse(notif.is_read)
self.assertEqual(notif.typeNotification, TypeNotif.NOTIF_MESSAGE)

View File

@ -0,0 +1,27 @@
from django.test import TestCase
from .models import Planning
from Establishment.models import Establishment
from School.models import SchoolClass
class PlanningModelTest(TestCase):
def setUp(self):
self.establishment = Establishment.objects.create(
name="École Test",
address="1 rue de l'École",
total_capacity=100,
establishment_type=[1],
evaluation_frequency=1,
licence_code="ABC123"
)
self.school_class = SchoolClass.objects.create(
atmosphere_name="Classe Test",
establishment=self.establishment
)
def test_create_planning(self):
planning = Planning.objects.create(
school_class=self.school_class,
establishment=self.establishment
)
self.assertEqual(planning.school_class, self.school_class)
self.assertEqual(planning.establishment, self.establishment)

View File

@ -1,3 +1,77 @@
from django.test import TestCase
from .models import Speciality, Teacher, SchoolClass, Planning, Discount, Fee, PaymentPlan, PaymentMode, Competency, EstablishmentCompetency
from Establishment.models import Establishment
from Common.models import Category, PaymentPlanType, PaymentModeType
from datetime import date
# Create your tests here.
class SpecialityModelTest(TestCase):
def test_create_speciality(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
speciality = Speciality.objects.create(name="Maths", establishment=est)
self.assertEqual(speciality.name, "Maths")
self.assertEqual(speciality.establishment, est)
class TeacherModelTest(TestCase):
def test_create_teacher(self):
teacher = Teacher.objects.create(last_name="Martin", first_name="Paul")
self.assertEqual(teacher.last_name, "Martin")
self.assertEqual(teacher.first_name, "Paul")
class SchoolClassModelTest(TestCase):
def test_create_school_class(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
school_class = SchoolClass.objects.create(atmosphere_name="Classe A", establishment=est)
self.assertEqual(school_class.atmosphere_name, "Classe A")
self.assertEqual(school_class.establishment, est)
class PlanningModelTest(TestCase):
def test_create_planning(self):
school_class = SchoolClass.objects.create(atmosphere_name="Classe B", establishment=Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A"))
planning = Planning.objects.create(school_class=school_class)
self.assertEqual(planning.school_class, school_class)
class DiscountModelTest(TestCase):
def test_create_discount(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
discount = Discount.objects.create(name="Réduction", establishment=est)
self.assertEqual(discount.name, "Réduction")
self.assertEqual(discount.establishment, est)
class FeeModelTest(TestCase):
def test_create_fee(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
fee = Fee.objects.create(name="Frais", establishment=est)
self.assertEqual(fee.name, "Frais")
self.assertEqual(fee.establishment, est)
class PaymentPlanModelTest(TestCase):
def test_create_payment_plan(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
plan_type = PaymentPlanType.objects.create(code="A", label="Plan A")
payment_plan = PaymentPlan.objects.create(plan_type=plan_type, establishment=est)
self.assertEqual(payment_plan.plan_type, plan_type)
self.assertEqual(payment_plan.establishment, est)
class PaymentModeModelTest(TestCase):
def test_create_payment_mode(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
mode_type = PaymentModeType.objects.create(label="Espèces")
payment_mode = PaymentMode.objects.create(mode=mode_type, establishment=est)
self.assertEqual(payment_mode.mode, mode_type)
self.assertEqual(payment_mode.establishment, est)
class CompetencyModelTest(TestCase):
def test_create_competency(self):
cat = Category.objects.create(name="Maths", domain_id=1)
comp = Competency.objects.create(name="Compétence 1", category=cat)
self.assertEqual(comp.name, "Compétence 1")
self.assertEqual(comp.category, cat)
class EstablishmentCompetencyModelTest(TestCase):
def test_create_establishment_competency(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
cat = Category.objects.create(name="Maths", domain_id=1)
comp = Competency.objects.create(name="Compétence 2", category=cat)
est_comp = EstablishmentCompetency.objects.create(establishment=est, competency=comp)
self.assertEqual(est_comp.establishment, est)
self.assertEqual(est_comp.competency, comp)

View File

@ -0,0 +1,97 @@
from django.test import TestCase
from .models import Language, Guardian, Sibling, BilanCompetence, Student, RegistrationFileGroup, RegistrationForm, RegistrationSchoolFileMaster, RegistrationParentFileMaster, RegistrationSchoolFileTemplate, StudentCompetency, RegistrationParentFileTemplate, AbsenceManagement
from django.utils import timezone
from datetime import date
from Establishment.models import Establishment
class LanguageModelTest(TestCase):
def test_create_language(self):
lang = Language.objects.create(label="Français")
self.assertEqual(lang.label, "Français")
self.assertIsNotNone(lang.id)
class GuardianModelTest(TestCase):
def test_create_guardian(self):
guardian = Guardian.objects.create(last_name="Dupont", first_name="Jean")
self.assertEqual(guardian.last_name, "Dupont")
self.assertEqual(guardian.first_name, "Jean")
class SiblingModelTest(TestCase):
def test_create_sibling(self):
sibling = Sibling.objects.create(last_name="Martin", first_name="Julie")
self.assertEqual(sibling.last_name, "Martin")
self.assertEqual(sibling.first_name, "Julie")
class BilanCompetenceModelTest(TestCase):
def test_create_bilan(self):
student = Student.objects.create(last_name="Test", first_name="Eleve")
bilan = BilanCompetence.objects.create(student=student, period="T1-2024_2025")
self.assertEqual(bilan.student, student)
self.assertEqual(bilan.period, "T1-2024_2025")
class StudentModelTest(TestCase):
def test_create_student(self):
student = Student.objects.create(last_name="Durand", first_name="Paul")
self.assertEqual(student.last_name, "Durand")
self.assertEqual(student.first_name, "Paul")
class RegistrationFileGroupModelTest(TestCase):
def test_create_group(self):
group = RegistrationFileGroup.objects.create(name="Groupe A")
self.assertEqual(group.name, "Groupe A")
class RegistrationFormModelTest(TestCase):
def test_create_registration_form(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
student = Student.objects.create(last_name="Test", first_name="Eleve")
group = RegistrationFileGroup.objects.create(name="Groupe B", establishment=est)
form = RegistrationForm.objects.create(student=student, fileGroup=group, establishment=est)
self.assertEqual(form.student, student)
self.assertEqual(form.fileGroup, group)
self.assertEqual(form.establishment, est)
class RegistrationSchoolFileMasterModelTest(TestCase):
def test_create_school_file_master(self):
master = RegistrationSchoolFileMaster.objects.create(id=1, name="Doc école")
self.assertEqual(master.name, "Doc école")
class RegistrationParentFileMasterModelTest(TestCase):
def test_create_parent_file_master(self):
master = RegistrationParentFileMaster.objects.create(name="Doc parent")
self.assertEqual(master.name, "Doc parent")
class RegistrationSchoolFileTemplateModelTest(TestCase):
def test_create_school_file_template(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
master = RegistrationSchoolFileMaster.objects.create(id=2, name="Doc école 2")
student = Student.objects.create(last_name="Test", first_name="Eleve")
group = RegistrationFileGroup.objects.create(name="Groupe C", establishment=est)
form = RegistrationForm.objects.create(student=student, fileGroup=group, establishment=est)
template = RegistrationSchoolFileTemplate.objects.create(id=2, master=master, name="Fichier école", registration_form=form)
self.assertEqual(template.name, "Fichier école")
self.assertEqual(template.master, master)
class StudentCompetencyModelTest(TestCase):
def test_create_student_competency(self):
student = Student.objects.create(last_name="Test", first_name="Eleve")
# Pour le test, on suppose qu'un modèle School.EstablishmentCompetency existe et est importable
# Ici, on ne peut pas créer l'objet sans ce modèle, donc ce test est à adapter selon la base réelle
pass
class RegistrationParentFileTemplateModelTest(TestCase):
def test_create_parent_file_template(self):
est = Establishment.objects.create(name="École Test", address="1 rue", total_capacity=10, establishment_type=[1], evaluation_frequency=1, licence_code="A")
master = RegistrationParentFileMaster.objects.create(name="Doc parent 2")
student = Student.objects.create(last_name="Test", first_name="Eleve")
group = RegistrationFileGroup.objects.create(name="Groupe D", establishment=est)
form = RegistrationForm.objects.create(student=student, fileGroup=group, establishment=est)
template = RegistrationParentFileTemplate.objects.create(master=master, registration_form=form)
self.assertEqual(template.master, master)
self.assertEqual(template.registration_form, form)
class AbsenceManagementModelTest(TestCase):
def test_create_absence(self):
student = Student.objects.create(last_name="Test", first_name="Eleve")
absence = AbsenceManagement.objects.create(student=student, day=date.today())
self.assertEqual(absence.student, student)
self.assertEqual(absence.day, date.today())

3
Back-End/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = N3wtSchool.settings
python_files = tests.py test_*.py *_tests.py

View File

@ -66,8 +66,10 @@ urllib3==2.2.3
vine==5.1.0
wcwidth==0.2.13
webencodings==0.5.1
watchfiles
xhtml2pdf==0.2.16
channels==4.0.0
channels-redis==4.1.0
daphne==4.1.0
pytest
pytest-django
pytest-json-report

View File

@ -1,6 +1,5 @@
import subprocess
import os
from watchfiles import run_process
def run_command(command):
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -12,7 +11,6 @@ def run_command(command):
return process.returncode
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
commands = [
["python", "manage.py", "collectstatic", "--noinput"],
@ -34,55 +32,23 @@ test_commands = [
["python", "manage.py", "init_mock_datas"]
]
def run_daphne():
try:
result = subprocess.run([
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
])
return result.returncode
except KeyboardInterrupt:
print("Arrêt de Daphne (KeyboardInterrupt)")
return 0
for command in commands:
if run_command(command) != 0:
exit(1)
if __name__ == "__main__":
for command in commands:
if run_command(command) != 0:
exit(1)
#if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
#if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
# Lancer les processus en parallèle
if watch_mode:
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
celery_beat = subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
try:
run_process(
'.',
target=run_daphne
)
except KeyboardInterrupt:
print("Arrêt demandé (KeyboardInterrupt)")
finally:
celery_worker.terminate()
celery_beat.terminate()
celery_worker.wait()
celery_beat.wait()
else:
processes = [
subprocess.Popen([
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
]
try:
for process in processes:
process.wait()
except KeyboardInterrupt:
print("Arrêt demandé (KeyboardInterrupt)")
for process in processes:
process.terminate()
for process in processes:
process.wait()
processes = [
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
]
# Attendre la fin des processus
for process in processes:
process.wait()

View File

@ -1,12 +1,12 @@
{
"name": "n3wt-school-front-end",
"version": "0.0.3",
"version": "0.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n3wt-school-front-end",
"version": "0.0.3",
"version": "0.0.1",
"dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2",
@ -29,7 +29,6 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18",
"react-hook-form": "^7.62.0",
"react-international-phone": "^4.5.0",
"react-quill": "^2.0.0",
"react-tooltip": "^5.28.0"
@ -8835,21 +8834,6 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-international-phone": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
@ -17176,12 +17160,6 @@
"scheduler": "^0.23.2"
}
},
"react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"requires": {}
},
"react-international-phone": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",

View File

@ -35,20 +35,19 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18",
"react-hook-form": "^7.62.0",
"react-international-phone": "^4.5.0",
"react-quill": "^2.0.0",
"react-tooltip": "^5.28.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/user-event": "^14.4.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.2.11",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14"
}

View File

@ -1,6 +1,6 @@
'use client';
import React, { useState, useEffect } from 'react';
import SelectChoice from '@/components/Form/SelectChoice';
import SelectChoice from '@/components/SelectChoice';
import AcademicResults from '@/components/Grades/AcademicResults';
import Attendance from '@/components/Grades/Attendance';
import Remarks from '@/components/Grades/Remarks';
@ -9,7 +9,7 @@ import Homeworks from '@/components/Grades/Homeworks';
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import logger from '@/utils/logger';
import {
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
@ -29,7 +29,7 @@ import { useClasses } from '@/context/ClassesContext';
import { Award, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/Form/InputText';
import InputText from '@/components/InputText';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import GradeView from '@/components/Grades/GradeView';
import {
fetchStudentCompetencies,

View File

@ -2,9 +2,9 @@
import React, { useState, useEffect } from 'react';
import Tab from '@/components/Tab';
import TabContent from '@/components/TabContent';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
import Button from '@/components/Button';
import InputText from '@/components/InputText';
import CheckBox from '@/components/CheckBox'; // Import du composant CheckBox
import logger from '@/utils/logger';
import {
fetchSmtpSettings,

View File

@ -8,9 +8,9 @@ import { fetchClasse } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext';
import Button from '@/components/Form/Button';
import SelectChoice from '@/components/Form/SelectChoice';
import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Button';
import SelectChoice from '@/components/SelectChoice';
import CheckBox from '@/components/CheckBox';
import {
fetchAbsences,
createAbsences,

View File

@ -2,17 +2,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, Mail } from 'lucide-react';
import InputTextIcon from '@/components/Form/InputTextIcon';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import Button from '@/components/Form/Button';
import InputTextIcon from '@/components/InputTextIcon';
import ToggleSwitch from '@/components/ToggleSwitch';
import Button from '@/components/Button';
import Table from '@/components/Table';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import SectionTitle from '@/components/SectionTitle';
import InputPhone from '@/components/Form/InputPhone';
import CheckBox from '@/components/Form/CheckBox';
import RadioList from '@/components/Form/RadioList';
import SelectChoice from '@/components/Form/SelectChoice';
import InputPhone from '@/components/InputPhone';
import CheckBox from '@/components/CheckBox';
import RadioList from '@/components/RadioList';
import SelectChoice from '@/components/SelectChoice';
import Loader from '@/components/Loader';
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
import logger from '@/utils/logger';

View File

@ -40,8 +40,8 @@ import {
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext';
import logger from '@/utils/logger';
import { PhoneLabel } from '@/components/Form/PhoneLabel';
import FileUpload from '@/components/Form/FileUpload';
import { PhoneLabel } from '@/components/PhoneLabel';
import FileUpload from '@/components/FileUpload';
import FilesModal from '@/components/Inscription/FilesModal';
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
@ -250,12 +250,7 @@ export default function Page({ params: { locale } }) {
}, 500); // Debounce la recherche
return () => clearTimeout(timeoutId);
}
}, [
searchTerm,
selectedEstablishmentId,
currentSchoolYearPage,
itemsPerPage,
]);
}, [searchTerm, selectedEstablishmentId, currentSchoolYearPage, itemsPerPage]);
/**
* UseEffect to update page count of tab

View File

@ -1,10 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import React from 'react';
import Button from '@/components/Form/Button';
import Button from '@/components/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');
@ -15,7 +13,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>
);
}

View File

@ -11,7 +11,7 @@ import {
CalendarDays,
} from 'lucide-react';
import StatusLabel from '@/components/StatusLabel';
import FileUpload from '@/components/Form/FileUpload';
import FileUpload from '@/components/FileUpload';
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
import {
fetchChildren,

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState } from 'react';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import Button from '@/components/Button';
import InputText from '@/components/InputText';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';

View File

@ -3,9 +3,9 @@ import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useRouter } from 'next/navigation';
import InputTextIcon from '@/components/Form/InputTextIcon';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Form/Button'; // Importez le composant Button
import Button from '@/components/Button'; // Importez le composant Button
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url';
import { login } from '@/app/actions/authAction';
@ -35,7 +35,11 @@ export default function Page() {
logger.debug('Sign In Result', result);
if (result.error) {
showNotification(result.error, 'error', 'Erreur');
showNotification(
result.error,
'error',
'Erreur'
);
setIsLoading(false);
} else {
// On initialise le contexte establishement avec la session
@ -46,7 +50,11 @@ export default function Page() {
if (url) {
router.push(url);
} else {
showNotification('Type de rôle non géré', 'error', 'Erreur');
showNotification(
'Type de rôle non géré',
'error',
'Erreur'
);
}
});
setIsLoading(false);

View File

@ -3,9 +3,9 @@
import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import InputTextIcon from '@/components/Form/InputTextIcon';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import { User } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext';
@ -25,13 +25,25 @@ export default function Page() {
.then((data) => {
logger.debug('Success:', data);
if (data.message !== '') {
showNotification(data.message, 'success', 'Succès');
showNotification(
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
if (data.errorMessage) {
showNotification(data.errorMessage, 'error', 'Erreur');
showNotification(
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) {
showNotification(data.errorFields.email, 'error', 'Erreur');
showNotification(
data.errorFields.email,
'error',
'Erreur'
);
}
}
setIsLoading(false);

View File

@ -5,9 +5,9 @@ import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/Form/InputTextIcon';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { KeySquare } from 'lucide-react';
import { useCsrfToken } from '@/context/CsrfContext';
@ -33,12 +33,21 @@ export default function Page() {
resetPassword(uuid, data, csrfToken)
.then((data) => {
if (data.message !== '') {
logger.debug('Success:', data);
showNotification(data.message, 'success', 'Succès');
showNotification(
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
if (data.errorMessage) {
showNotification(data.errorMessage, 'error', 'Erreur');
showNotification(
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) {
showNotification(
data.errorFields.password1 || data.errorFields.password2,

View File

@ -4,9 +4,9 @@ import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/Form/InputTextIcon';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import { User, KeySquare } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext';
@ -36,16 +36,22 @@ export default function Page() {
.then((data) => {
logger.debug('Success:', data);
if (data.message !== '') {
showNotification(data.message, 'success', 'Succès');
showNotification(
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
if (data.errorMessage) {
showNotification(data.errorMessage, 'error', 'Erreur');
showNotification(
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) {
showNotification(
data.errorFields.email ||
data.errorFields.password1 ||
data.errorFields.password2,
data.errorFields.email || data.errorFields.password1 || data.errorFields.password2,
'error',
'Erreur'
);

View File

@ -9,10 +9,10 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage';
import RecipientInput from '@/components/RecipientInput';
import { useRouter } from 'next/navigation'; // Ajoute cette ligne
import WisiwigTextArea from '@/components/Form/WisiwigTextArea';
import WisiwigTextArea from '@/components/WisiwigTextArea';
import logger from '@/utils/logger';
import InputText from '@/components/Form/InputText';
import Button from '@/components/Form/Button';
import InputText from '@/components/InputText';
import Button from '@/components/Button';
export default function EmailSender({ csrfToken }) {
const [recipients, setRecipients] = useState([]);

View File

@ -7,7 +7,7 @@ const CheckBox = ({
handleChange,
fieldName,
itemLabelFunc = () => null,
horizontal = false,
horizontal,
}) => {
// Vérifier si formData[fieldName] est un tableau ou une valeur booléenne
const isChecked = Array.isArray(formData[fieldName])
@ -22,7 +22,7 @@ const CheckBox = ({
{horizontal && (
<label
htmlFor={`${fieldName}-${item.id}`}
className="block text-sm text-center mb-1 font-medium text-gray-700 cursor-pointer"
className="block text-sm text-center mb-1 font-medium text-gray-700"
>
{itemLabelFunc(item)}
</label>
@ -40,7 +40,7 @@ const CheckBox = ({
{!horizontal && (
<label
htmlFor={`${fieldName}-${item.id}`}
className="block text-sm font-medium text-gray-700 cursor-pointer"
className="block text-sm text-center mb-1 font-medium text-gray-700"
>
{itemLabelFunc(item)}
</label>

View File

@ -1,589 +0,0 @@
import React, { useState, useEffect } 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({
id: '',
label: '',
type: 'text',
required: false,
icon: '',
options: [],
text: '',
placeholder: '',
acceptTypes: '',
maxSize: 5, // 5MB par défaut
checked: false,
validation: {
pattern: '',
minLength: '',
maxLength: '',
},
});
const [showIconPicker, setShowIconPicker] = useState(false);
const [newOption, setNewOption] = useState('');
const { control, handleSubmit, reset, setValue } = useForm();
// Mettre à jour l'état et les valeurs du formulaire lorsque editingField change
useEffect(() => {
if (isOpen) {
const defaultValues = editingField || {
id: '',
label: '',
type: 'text',
required: false,
icon: '',
options: [],
text: '',
placeholder: '',
acceptTypes: '',
maxSize: 5,
checked: false,
validation: {
pattern: '',
minLength: '',
maxLength: '',
},
};
setCurrentField(defaultValues);
// Réinitialiser le formulaire avec les valeurs de l'élément à éditer
reset({
type: defaultValues.type,
label: defaultValues.label,
placeholder: defaultValues.placeholder,
required: defaultValues.required,
icon: defaultValues.icon,
text: defaultValues.text,
acceptTypes: defaultValues.acceptTypes,
maxSize: defaultValues.maxSize,
checked: defaultValues.checked,
validation: defaultValues.validation,
});
}
}, [isOpen, editingField, reset]);
// 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>
)}
{currentField.type === 'radio' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Options des boutons radio
</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>
)}
{currentField.type === 'phone' && (
<Controller
name="validation.pattern"
control={control}
defaultValue={currentField.validation?.pattern || ''}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Format de téléphone (optionnel, exemple: ^\\+?[0-9]{10,15}$)"
name="phonePattern"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
validation: {
...currentField.validation,
pattern: e.target.value,
},
});
}}
/>
)}
/>
)}
{currentField.type === 'file' && (
<>
<Controller
name="acceptTypes"
control={control}
defaultValue={currentField.acceptTypes || ''}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Types de fichiers acceptés (ex: .pdf,.jpg,.png)"
name="acceptTypes"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
acceptTypes: e.target.value,
});
}}
/>
)}
/>
<Controller
name="maxSize"
control={control}
defaultValue={currentField.maxSize || 5}
render={({ field: { onChange, value } }) => (
<InputTextIcon
label="Taille maximale (MB)"
name="maxSize"
type="number"
value={value}
onChange={(e) => {
onChange(e.target.value);
setCurrentField({
...currentField,
maxSize: parseInt(e.target.value) || 5,
});
}}
/>
)}
/>
</>
)}
{currentField.type === 'checkbox' && (
<>
<div className="flex items-center mt-2">
<Controller
name="checked"
control={control}
defaultValue={currentField.checked || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="defaultChecked"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
checked: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="defaultChecked">Coché par défaut</label>
</div>
<div className="flex items-center mt-2">
<Controller
name="horizontal"
control={control}
defaultValue={currentField.horizontal || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="horizontal"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
horizontal: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="horizontal">Label au-dessus (horizontal)</label>
</div>
</>
)}
{currentField.type === 'toggle' && (
<div className="flex items-center mt-2">
<Controller
name="checked"
control={control}
defaultValue={currentField.checked || false}
render={({ field: { onChange, value } }) => (
<input
type="checkbox"
id="defaultToggled"
checked={value}
onChange={(e) => {
onChange(e.target.checked);
setCurrentField({
...currentField,
checked: e.target.checked,
});
}}
className="mr-2"
/>
)}
/>
<label htmlFor="defaultToggled">Activé par défaut</label>
</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>
);
}

View File

@ -1,443 +0,0 @@
import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form';
import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react';
import Button from './Button';
import DjangoCSRFToken from '../DjangoCSRFToken';
import WisiwigTextArea from './WisiwigTextArea';
import RadioList from './RadioList';
import CheckBox from './CheckBox';
import ToggleSwitch from './ToggleSwitch';
import InputPhone from './InputPhone';
import FileUpload from './FileUpload';
/*
* Récupère une icône Lucide par son nom.
*/
export function getIcon(name) {
if (Object.keys(LucideIcons).includes(name)) {
const Icon = LucideIcons[name];
return Icon ?? null;
} else {
return null;
}
}
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,
csrfToken,
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel)
}) {
const {
handleSubmit,
control,
formState: { errors },
reset,
} = useForm();
// Fonction utilitaire pour envoyer les données au backend
const sendFormDataToBackend = async (formData) => {
try {
// Cette fonction peut être remplacée par votre propre implémentation
// Exemple avec fetch:
const response = await fetch('/api/submit-form', {
method: 'POST',
body: formData,
// Les en-têtes sont automatiquement définis pour FormData
});
if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status}`);
}
const result = await response.json();
logger.debug('Envoi réussi:', result);
return result;
} catch (error) {
logger.error("Erreur lors de l'envoi:", error);
throw error;
}
};
const onSubmit = async (data) => {
logger.debug('=== DÉBUT onSubmit ===');
logger.debug('Réponses :', data);
try {
// Vérifier si nous avons des fichiers dans les données
const hasFiles = Object.keys(data).some((key) => {
return (
data[key] instanceof FileList ||
(data[key] && data[key][0] instanceof File)
);
});
if (hasFiles) {
// Utiliser FormData pour l'envoi de fichiers
const formData = new FormData();
// Ajouter l'ID du formulaire
formData.append('formId', formConfig.id.toString());
// Traiter chaque champ et ses valeurs
Object.keys(data).forEach((key) => {
const value = data[key];
if (
value instanceof FileList ||
(value && value[0] instanceof File)
) {
// Gérer les champs de type fichier
if (value.length > 0) {
for (let i = 0; i < value.length; i++) {
formData.append(`files.${key}`, value[i]);
}
}
} else {
// Gérer les autres types de champs
formData.append(
`data.${key}`,
value !== undefined ? value.toString() : ''
);
}
});
if (onFormSubmit) {
// Utiliser le callback personnalisé si fourni
await onFormSubmit(formData, true);
} else {
// Sinon, utiliser la fonction par défaut
await sendFormDataToBackend(formData);
alert('Formulaire avec fichier(s) envoyé avec succès');
}
} else {
// Pas de fichier, on peut utiliser JSON
const formattedData = {
formId: formConfig.id,
responses: { ...data },
};
if (onFormSubmit) {
// Utiliser le callback personnalisé si fourni
await onFormSubmit(formattedData, false);
} else {
// Afficher un message pour démonstration
alert('Données reçues : ' + JSON.stringify(formattedData, null, 2));
}
}
reset(); // Réinitialiser le formulaire après soumission
} catch (error) {
logger.error('Erreur lors de la soumission du formulaire:', error);
alert(`Erreur lors de l'envoi du formulaire: ${error.message}`);
}
logger.debug('=== FIN onSubmit ===');
};
const onError = (errors) => {
logger.error('=== ERREURS DE VALIDATION ===');
logger.error('Erreurs :', errors);
alert('Erreurs de validation : ' + JSON.stringify(errors, null, 2));
};
return (
<form
onSubmit={handleSubmit(onSubmit, onError)}
className="max-w-md mx-auto"
>
{csrfToken ? <DjangoCSRFToken csrfToken={csrfToken} /> : null}
<h2 className="text-2xl font-bold text-center mb-4">
{formConfig.title}
</h2>
{formConfig.fields.map((field) => (
<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' ||
field.type === 'date') && (
<Controller
name={field.id}
control={control}
rules={{
required: field.required,
pattern: field.validation?.pattern
? new RegExp(field.validation.pattern)
: undefined,
minLength: field.validation?.minLength,
maxLength: field.validation?.maxLength,
}}
render={({ field: { onChange, value, name } }) => (
<InputTextIcon
label={field.label}
required={field.required}
IconItem={field.icon ? getIcon(field.icon) : null}
type={field.type}
name={name}
value={value || ''}
onChange={onChange}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'phone' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<InputPhone
label={field.label}
required={field.required}
name={name}
value={value || ''}
onChange={onChange}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'select' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<SelectChoice
label={field.label}
required={field.required}
name={name}
selected={value || ''}
callback={onChange}
choices={field.options.map((e) => ({ label: e, value: e }))}
placeHolder={`Sélectionner ${field.label.toLowerCase()}`}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'radio' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<RadioList
items={field.options.map((option, idx) => ({
id: idx,
label: option,
}))}
formData={{
[field.id]: value
? field.options.findIndex((o) => o === value)
: '',
}}
handleChange={(e) =>
onChange(field.options[parseInt(e.target.value)])
}
fieldName={field.id}
sectionLabel={field.label}
required={field.required}
/>
)}
/>
)}
{field.type === 'checkbox' && (
<Controller
name={field.id}
control={control}
defaultValue={field.checked || false}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<div>
<CheckBox
item={{ id: field.id, label: field.label }}
formData={{ [field.id]: value || false }}
handleChange={(e) => onChange(e.target.checked)}
fieldName={field.id}
itemLabelFunc={(item) => item.label}
horizontal={field.horizontal || false}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
{field.type === 'toggle' && (
<Controller
name={field.id}
control={control}
defaultValue={field.checked || false}
rules={{ required: field.required }}
render={({ field: { onChange, value, name } }) => (
<div>
<ToggleSwitch
name={field.id}
label={field.label + (field.required ? ' *' : '')}
checked={value || false}
onChange={(e) => onChange(e.target.checked)}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
{field.required
? `${field.label} est requis`
: 'Champ invalide'}
</p>
)}
</div>
)}
/>
)}
{field.type === 'file' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<FileUpload
selectionMessage={field.label}
required={field.required}
uploadedFileName={value ? value[0]?.name : null}
onFileSelect={(file) => {
// Créer un objet de type FileList similaire pour la compatibilité
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
onChange(dataTransfer.files);
}}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
{field.type === 'textarea' && (
<Controller
name={field.id}
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<WisiwigTextArea
label={field.label}
placeholder={field.placeholder}
value={value || ''}
onChange={onChange}
required={field.required}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)}
/>
)}
</div>
))}
<div className="form-group-submit mt-4">
<Button
type="submit"
primary
text={formConfig.submitLabel ? 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>
</form>
);
}

View File

@ -1,616 +0,0 @@
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,
Phone,
Radio,
ToggleLeft,
CheckSquare,
FileUp,
} from 'lucide-react';
const FIELD_TYPES_ICON = {
text: { icon: TextCursorInput },
email: { icon: AtSign },
phone: { icon: Phone },
date: { icon: Calendar },
select: { icon: ChevronDown },
radio: { icon: Radio },
checkbox: { icon: CheckSquare },
toggle: { icon: ToggleLeft },
file: { icon: FileUp },
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: ['select', 'radio'].includes(data.type)
? currentField.options
: undefined,
icon: data.icon || currentField.icon || undefined,
placeholder: data.placeholder || undefined,
text: isContentTypeOnly ? data.text : undefined,
checked: ['checkbox', 'toggle'].includes(data.type)
? currentField.checked
: undefined,
horizontal:
data.type === 'checkbox' ? currentField.horizontal : undefined,
acceptTypes: data.type === 'file' ? currentField.acceptTypes : undefined,
maxSize: data.type === 'file' ? currentField.maxSize : undefined,
validation: ['phone', 'email', 'text'].includes(data.type)
? currentField.validation
: 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&apos;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&apos;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>
);
}

View File

@ -1,19 +0,0 @@
export const FIELD_TYPES = [
{ value: 'text', label: 'Texte' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Téléphone' },
{ value: 'date', label: 'Date' },
{ value: 'select', label: 'Liste déroulante' },
{ value: 'radio', label: 'Boutons radio' },
{ value: 'checkbox', label: 'Case à cocher' },
{ value: 'toggle', label: 'Interrupteur' },
{ value: 'file', label: 'Upload de fichier' },
{ 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' },
];

View File

@ -1,145 +0,0 @@
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>
);
}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import Button from '@/components/Form/Button';
import ToggleSwitch from '@/components/ToggleSwitch';
import Button from '@/components/Button';
import Popup from '@/components/Popup';
import { useNotification } from '@/context/NotificationContext';

View File

@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from 'react';
import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react';
import RadioList from '@/components/Form/RadioList';
import RadioList from '@/components/RadioList';
const LEVELS = [
{ value: 0, label: 'Non évalué' },

View File

@ -1,5 +1,3 @@
import React from 'react';
export default function InputTextIcon({
name,
type,
@ -33,11 +31,9 @@ export default function InputTextIcon({
!enable ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
>
{IconItem ? (
<span className="inline-flex min-h-9 items-center px-3 rounded-l-md bg-gray-50 text-gray-500 text-sm">
<IconItem />
</span>
) : null}
<span className="inline-flex min-h-9 items-center px-3 rounded-l-md bg-gray-50 text-gray-500 text-sm">
{IconItem && <IconItem />}
</span>
<input
type={type}
id={name}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import Table from '@/components/Table';
import FileUpload from '@/components/Form/FileUpload';
import FileUpload from '@/components/FileUpload';
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
import { BASE_URL } from '@/utils/Url';
import Popup from '@/components/Popup';

View File

@ -1,6 +1,6 @@
// Import des dépendances nécessaires
import React, { useState, useEffect } from 'react';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
@ -220,7 +220,9 @@ export default function InscriptionFormShared({
.then((data) => {
setProfiles(data);
})
.catch((error) => logger.error('Error fetching profiles : ', error));
.catch((error) =>
logger.error('Error fetching profiles : ', error)
);
if (selectedEstablishmentId) {
// Fetch data for registration payment modes

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import SelectChoice from '@/components/Form/SelectChoice';
import RadioList from '@/components/Form/RadioList';
import SelectChoice from '@/components/SelectChoice';
import RadioList from '@/components/RadioList';
import logger from '@/utils/logger';
export default function PaymentMethodSelector({

View File

@ -1,5 +1,5 @@
import InputText from '@/components/Form/InputText';
import InputPhone from '@/components/Form/InputPhone';
import InputText from '@/components/InputText';
import InputPhone from '@/components/InputPhone';
import React, { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2, Plus, Users } from 'lucide-react';

View File

@ -1,4 +1,4 @@
import InputText from '@/components/Form/InputText';
import InputText from '@/components/InputText';
import React, { useEffect } from 'react';
import { Trash2, Plus, Users } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/Form/SelectChoice';
import InputText from '@/components/InputText';
import SelectChoice from '@/components/SelectChoice';
import Loader from '@/components/Loader';
import { fetchRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react';
import FileUpload from '@/components/Form/FileUpload';
import FileUpload from '@/components/FileUpload';
import { BASE_URL } from '@/utils/Url';
import { levels, genders } from '@/utils/constants';
@ -112,10 +112,13 @@ export default function StudentInfoForm({
(field === 'birth_place' &&
(!formData.birth_place || formData.birth_place.trim() === '')) ||
(field === 'birth_postal_code' &&
(!formData.birth_postal_code ||
(
!formData.birth_postal_code ||
String(formData.birth_postal_code).trim() === '' ||
isNaN(Number(formData.birth_postal_code)) ||
!Number.isInteger(Number(formData.birth_postal_code)))) ||
!Number.isInteger(Number(formData.birth_postal_code))
)
) ||
(field === 'address' &&
(!formData.address || formData.address.trim() === '')) ||
(field === 'attending_physician' &&

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import ToggleSwitch from '@/components/ToggleSwitch';
import SelectChoice from '@/components/SelectChoice';
import { BASE_URL } from '@/utils/Url';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
@ -10,7 +10,7 @@ import {
import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button';
import Button from '@/components/Button';
export default function ValidateSubscription({
studentId,

View File

@ -4,7 +4,7 @@ import Table from '@/components/Table';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext';
import CheckBox from '@/components/Form/CheckBox';
import CheckBox from '@/components/CheckBox';
const paymentPlansOptions = [
{ id: 1, name: '1 fois', frequency: 1 },

View File

@ -2,9 +2,9 @@ import React, { useState, useRef, useCallback } from 'react';
import TreeView from '@/components/Structure/Competencies/TreeView';
import SectionHeader from '@/components/SectionHeader';
import { Award, CheckCircle } from 'lucide-react';
import SelectChoice from '@/components/Form/SelectChoice';
import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button';
import SelectChoice from '@/components/SelectChoice';
import CheckBox from '@/components/CheckBox';
import Button from '@/components/Button';
import { useEstablishment } from '@/context/EstablishmentContext';
import {
fetchEstablishmentCompetencies,

View File

@ -2,10 +2,10 @@ import { Trash2, Edit3, ZoomIn, Users, Check, X, Hand } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/Form/SelectChoice';
import InputText from '@/components/InputText';
import SelectChoice from '@/components/SelectChoice';
import TeacherItem from '@/components/Structure/Configuration/TeacherItem';
import MultiSelect from '@/components/Form/MultiSelect';
import MultiSelect from '@/components/MultiSelect';
import LevelLabel from '@/components/CustomLabels/LevelLabel';
import { DndProvider, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

View File

@ -2,7 +2,7 @@ import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
import { useState } from 'react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
import InputTextWithColorIcon from '@/components/InputTextWithColorIcon';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';

View File

@ -2,11 +2,11 @@ import React, { useState, useEffect } from 'react';
import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import ToggleSwitch from '@/components/ToggleSwitch';
import { useCsrfToken } from '@/context/CsrfContext';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/Form/InputText';
import InputText from '@/components/InputText';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
import TeacherItem from './TeacherItem';
import logger from '@/utils/logger';

View File

@ -7,7 +7,7 @@ import {
} from '@/app/actions/registerFileGroupAction';
import { DocusealBuilder } from '@docuseal/react';
import logger from '@/utils/logger';
import MultiSelect from '@/components/Form/MultiSelect'; // Import du composant MultiSelect
import MultiSelect from '@/components/MultiSelect'; // Import du composant MultiSelect
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import Popup from '@/components/Popup';
@ -121,13 +121,7 @@ export default function FileUploadDocuSeal({
guardianDetails.forEach((guardian, index) => {
logger.debug('creation du clone avec required : ', is_required);
cloneTemplate(
templateMaster?.id,
guardian.email,
is_required,
selectedEstablishmentId,
apiDocuseal
)
cloneTemplate(templateMaster?.id, guardian.email, is_required, selectedEstablishmentId, apiDocuseal)
.then((clonedDocument) => {
// Sauvegarde des schoolFileTemplates clonés dans la base de données
const data = {

View File

@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
import Table from '@/components/Table';
import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/Form/MultiSelect';
import InputText from '@/components/InputText';
import MultiSelect from '@/components/MultiSelect';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
import { useCsrfToken } from '@/context/CsrfContext';
import SectionHeader from '@/components/SectionHeader';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import ToggleSwitch from '@/components/ToggleSwitch';
import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage';

View File

@ -2,8 +2,8 @@ import React, { useState } from 'react';
import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import CheckBox from '@/components/Form/CheckBox';
import InputText from '@/components/Form/InputText';
import CheckBox from '@/components/CheckBox';
import InputText from '@/components/InputText';
import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext';

View File

@ -2,8 +2,8 @@ import React, { useState } from 'react';
import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import CheckBox from '@/components/Form/CheckBox';
import InputText from '@/components/Form/InputText';
import CheckBox from '@/components/CheckBox';
import InputText from '@/components/InputText';
import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext';

View File

@ -4,10 +4,10 @@ import 'react-quill/dist/quill.snow.css';
const ReactQuill = dynamic(() => import('react-quill'), { ssr: false });
export default function WisiwigTextArea({
label = 'Zone de Texte',
label = 'Mail',
value,
onChange,
placeholder = 'Ecrivez votre texte ici...',
placeholder = 'Ecrivez votre mail ici...',
className = 'h-64',
required = false,
errorMsg,

View File

@ -36,6 +36,20 @@ services:
- database
command: python start.py
backend-test:
build:
context: ./Back-End
volumes:
- ./Back-End:/Back-End
env_file: "./conf/backend.env"
links:
- "database:database"
- "redis:redis"
depends_on:
- redis
- database
command: python manage.py test
volumes:
postgres-data:
redis-data:

View File

@ -1,94 +0,0 @@
# 🧭 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 de-mails
Lenvoi 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 lapplication.
### 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 dutiliser 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 nest 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 laide officielle de Google ici :
> [Créer un mot de passe dapplication 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.