feat: Configuration des compétences par cycle [#16]

This commit is contained in:
N3WT DE COMPET
2025-05-18 00:45:49 +02:00
parent 2888f8dcce
commit 4e5aab6db7
29 changed files with 1001 additions and 82 deletions

View File

@ -6,7 +6,9 @@ from rest_framework.views import APIView
from rest_framework import status
from .models import Establishment
from .serializers import EstablishmentSerializer
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from N3wtSchool.bdd import delete_object, getAllObjects
from School.models import Competency, EstablishmentCompetency
from django.db.models import Q
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
@ -20,7 +22,17 @@ class EstablishmentListCreateView(APIView):
establishment_data = JSONParser().parse(request)
establishment_serializer = EstablishmentSerializer(data=establishment_data)
if establishment_serializer.is_valid():
establishment_serializer.save()
establishment = establishment_serializer.save()
# Création des EstablishmentCompetency pour chaque compétence existante
competencies = Competency.objects.filter(
Q(end_of_cycle=True) | ~Q(level=None)
)
for competency in competencies:
EstablishmentCompetency.objects.get_or_create(
establishment=establishment,
competency=competency,
defaults={'is_required': True}
)
return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)

View File

@ -1,5 +1,6 @@
from django.db import models
from Auth.models import Profile
from django.utils import timezone
class Messagerie(models.Model):
id = models.AutoField(primary_key=True)

View File

@ -165,31 +165,24 @@ class Competency(models.Model):
class EstablishmentCompetency(models.Model):
"""
Relation entre un établissement et une compétence.
Permet de définir quelles compétences sont sélectionnées par un établissement.
Peut référencer une compétence de référence OU une compétence custom propre à l'établissement.
"""
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE)
competency = models.ForeignKey(Competency, on_delete=models.CASCADE)
is_selected = models.BooleanField(default=False)
competency = models.ForeignKey(
Competency, on_delete=models.CASCADE, null=True, blank=True,
help_text="Compétence de référence (optionnelle si custom)"
)
custom_name = models.TextField(null=True, blank=True, help_text="Nom de la compétence custom")
custom_category = models.ForeignKey(
Category, on_delete=models.CASCADE, null=True, blank=True,
help_text="Catégorie de la compétence custom"
)
is_required = models.BooleanField(default=True)
class Meta:
unique_together = ('establishment', 'competency')
unique_together = ('establishment', 'competency', 'custom_name', 'custom_category')
def __str__(self):
if self.competency:
return f"{self.establishment.name} - {self.competency.name}"
# class StudentCompetency(models.Model):
# """
# Relation entre un élève et une compétence.
# Permet d'attribuer une note à un élève pour une compétence.
# """
# student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='competency_scores')
# competency = models.ForeignKey(Competency, on_delete=models.CASCADE, related_name='student_scores')
# score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) # Note attribuée
# comment = models.TextField(blank=True, null=True) # Commentaire facultatif
# class Meta:
# unique_together = ('student', 'competency')
# def __str__(self):
# return f"{self.student} - {self.competency.name} - Score: {self.score}"
return f"{self.establishment.name} - {self.custom_name} (custom)"

View File

@ -1,13 +1,47 @@
from rest_framework import serializers
from .models import Teacher, Speciality, SchoolClass, Planning, LEVEL_CHOICES, Discount, Fee, PaymentPlan, PaymentMode
from .models import (
Teacher,
Speciality,
SchoolClass,
Planning,
LEVEL_CHOICES,
Discount,
Fee,
PaymentPlan,
PaymentMode,
Domain,
Category,
Competency,
EstablishmentCompetency
)
from Auth.models import Profile, ProfileRole
from Subscriptions.models import Student
from Establishment.models import Establishment
from Auth.serializers import ProfileRoleSerializer
from N3wtSchool import settings, bdd
from N3wtSchool import settings
from django.utils import timezone
import pytz
class DomainSerializer(serializers.ModelSerializer):
class Meta:
model = Domain
fields = '__all__'
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = '__all__'
class CompetencySerializer(serializers.ModelSerializer):
class Meta:
model = Competency
fields = '__all__'
class EstablishmentCompetencySerializer(serializers.ModelSerializer):
class Meta:
model = EstablishmentCompetency
fields = '__all__'
class SpecialitySerializer(serializers.ModelSerializer):
updated_date_formatted = serializers.SerializerMethodField()

View File

@ -2,7 +2,8 @@ import json
import os
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from School.models import Domain, Category, Competency
from Establishment.models import Establishment
from School.models import Domain, Category, Competency, EstablishmentCompetency
@receiver(post_migrate)
def load_json_data(sender, **kwargs):
@ -32,15 +33,15 @@ def load_json_data(sender, **kwargs):
for domain_data in data['domaines']:
# Vérifiez si le domaine existe déjà
domain, created = Domain.objects.get_or_create(name=domain_data['nom'], cycle=cycle)
domain, _ = Domain.objects.get_or_create(name=domain_data['nom'], cycle=cycle)
for category_data in domain_data['categories']:
# Vérifiez si la catégorie existe déjà
category, created = Category.objects.get_or_create(name=category_data['nom'], domain=domain)
category, _ = Category.objects.get_or_create(name=category_data['nom'], domain=domain)
for competency_data in category_data['competences']:
# Vérifiez si la compétence existe déjà
Competency.objects.get_or_create(
competency, _ = Competency.objects.get_or_create(
name=competency_data['nom'],
end_of_cycle=competency_data.get('fin_cycle', False),
level=competency_data.get('niveau'),

View File

@ -1,22 +1,18 @@
from django.urls import path, re_path
from .views import (
TeacherListCreateView,
TeacherDetailView,
SpecialityListCreateView,
SpecialityDetailView,
SchoolClassListCreateView,
SchoolClassDetailView,
PlanningListCreateView,
PlanningDetailView,
FeeListCreateView,
FeeDetailView,
DiscountListCreateView,
DiscountDetailView,
PaymentPlanListCreateView,
PaymentPlanDetailView,
PaymentModeListCreateView,
PaymentModeDetailView,
TeacherListCreateView, TeacherDetailView,
SpecialityListCreateView, SpecialityDetailView,
SchoolClassListCreateView, SchoolClassDetailView,
PlanningListCreateView, PlanningDetailView,
FeeListCreateView, FeeDetailView,
DiscountListCreateView, DiscountDetailView,
PaymentPlanListCreateView, PaymentPlanDetailView,
PaymentModeListCreateView, PaymentModeDetailView,
DomainListCreateView, DomainDetailView,
CategoryListCreateView, CategoryDetailView,
CompetencyListCreateView, CompetencyDetailView,
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
)
urlpatterns = [
@ -43,4 +39,16 @@ urlpatterns = [
re_path(r'^paymentModes$', PaymentModeListCreateView.as_view(), name="payment_mode_list_create"),
re_path(r'^paymentModes/(?P<id>[0-9]+)$', PaymentModeDetailView.as_view(), name="payment_mode_detail"),
re_path(r'^domains$', DomainListCreateView.as_view(), name="domain_list_create"),
re_path(r'^domains/(?P<id>[0-9]+)$', DomainDetailView.as_view(), name="domain_detail"),
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
re_path(r'^competencies$', CompetencyListCreateView.as_view(), name="competency_list_create"),
re_path(r'^competencies/(?P<id>[0-9]+)$', CompetencyDetailView.as_view(), name="competency_detail"),
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
]

View File

@ -12,7 +12,11 @@ from .models import (
Discount,
Fee,
PaymentPlan,
PaymentMode
PaymentMode,
Domain,
Category,
Competency,
EstablishmentCompetency
)
from .serializers import (
TeacherSerializer,
@ -22,9 +26,15 @@ from .serializers import (
DiscountSerializer,
FeeSerializer,
PaymentPlanSerializer,
PaymentModeSerializer
PaymentModeSerializer,
DomainSerializer,
CategorySerializer,
CompetencySerializer,
EstablishmentCompetencySerializer
)
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from django.db.models import Q
from collections import defaultdict
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
@ -411,3 +421,349 @@ class PaymentModeDetailView(APIView):
def delete(self, request, id):
return delete_object(PaymentMode, id)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainListCreateView(APIView):
def get(self, request):
domains = Domain.objects.all()
serializer = DomainSerializer(domains, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = DomainSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainDetailView(APIView):
def get(self, request, id):
try:
domain = Domain.objects.get(id=id)
serializer = DomainSerializer(domain)
return JsonResponse(serializer.data, safe=False)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
domain = Domain.objects.get(id=id)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = DomainSerializer(domain, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
domain = Domain.objects.get(id=id)
domain.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Domain.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# Répète la même logique pour Category, Competency, EstablishmentCompetency
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryListCreateView(APIView):
def get(self, request):
categories = Category.objects.all()
serializer = CategorySerializer(categories, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = CategorySerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryDetailView(APIView):
def get(self, request, id):
try:
category = Category.objects.get(id=id)
serializer = CategorySerializer(category)
return JsonResponse(serializer.data, safe=False)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
category = Category.objects.get(id=id)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = CategorySerializer(category, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
category = Category.objects.get(id=id)
category.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyListCreateView(APIView):
def get(self, request):
cycle = request.GET.get('cycle')
if cycle is None:
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
competencies_list = getAllObjects(Competency)
competencies_list = competencies_list.filter(
category__domain__cycle=cycle
).distinct()
serializer = CompetencySerializer(competencies_list, many=True)
print(f'len : {competencies_list.count()}')
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = CompetencySerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyDetailView(APIView):
def get(self, request, id):
try:
competency = Competency.objects.get(id=id)
serializer = CompetencySerializer(competency)
return JsonResponse(serializer.data, safe=False)
except Competency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
competency = Competency.objects.get(id=id)
except Competency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = CompetencySerializer(competency, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
competency = Competency.objects.get(id=id)
competency.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Competency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyListCreateView(APIView):
def get(self, request):
establishment_id = request.GET.get('establishment_id')
cycle = request.GET.get('cycle')
if not establishment_id or not cycle:
return JsonResponse({'error': 'establishment_id et cycle sont requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Toutes les compétences du cycle
competencies = Competency.objects.filter(category__domain__cycle=cycle).select_related('category', 'category__domain')
# Récupérer les EstablishmentCompetency pour l'établissement et le cycle
ec_qs = EstablishmentCompetency.objects.filter(
Q(competency__in=competencies) | Q(competency__isnull=True),
establishment_id=establishment_id
).select_related('competency', 'custom_category')
# Required = compétences de référence requises
required_ids = set(ec_qs.filter(is_required=True, competency__isnull=False).values_list('competency_id', flat=True))
# Custom = compétences custom (pas de lien vers Competency)
custom_ecs = ec_qs.filter(is_required=False, competency__isnull=True)
# Organisation par domaine > catégorie
result = []
selected_count = 0
domaines = Domain.objects.filter(cycle=cycle)
for domaine in domaines:
domaine_dict = {
"domaine_id": domaine.id,
"domaine_nom": domaine.name,
"categories": []
}
categories = domaine.categories.all()
for categorie in categories:
categorie_dict = {
"categorie_id": categorie.id,
"categorie_nom": categorie.name,
"competences": []
}
# Liste des noms de compétences custom pour cette catégorie
custom_for_cat = custom_ecs.filter(custom_category=categorie)
custom_names = set(ec.custom_name.strip().lower() for ec in custom_for_cat if ec.custom_name)
# Compétences de référence (on saute celles qui sont déjà en custom)
competences = categorie.competencies.all()
for comp in competences:
if comp.name.strip().lower() in custom_names:
continue # On n'affiche pas la compétence de référence si une custom du même nom existe
if comp.id in required_ids:
state = "required"
selected_count += 1
else:
state = "none"
categorie_dict["competences"].append({
"competence_id": comp.id,
"nom": comp.name,
"state": state
})
# Ajout des compétences custom
for ec in custom_for_cat:
categorie_dict["competences"].append({
"competence_id": ec.id,
"nom": ec.custom_name,
"state": "custom"
})
selected_count += 1
domaine_dict["categories"].append(categorie_dict)
result.append(domaine_dict)
return JsonResponse({
"selected_count": selected_count,
"data": result
}, safe=False)
def post(self, request):
"""
Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
Attendu dans le body :
[
{ "establishment_id": ..., "category_id": ..., "nom": ... },
...
]
"""
data = JSONParser().parse(request)
# Si data est un dict (un seul objet), on le met dans une liste
if isinstance(data, dict):
data = [data]
created = []
errors = []
for item in data:
establishment_id = item.get("establishment_id")
category_id = item.get("category_id")
nom = item.get("nom")
if not establishment_id or not category_id or not nom:
errors.append({"error": "establishment_id, category_id et nom sont requis", "item": item})
continue
try:
category = Category.objects.get(id=category_id)
# Vérifier si une compétence custom du même nom existe déjà pour cet établissement et cette catégorie
ec_exists = EstablishmentCompetency.objects.filter(
establishment_id=establishment_id,
competency__isnull=True,
custom_name=nom,
).exists()
if ec_exists:
errors.append({"error": "Une compétence custom de ce nom existe déjà pour cet établissement", "item": item})
continue
ec = EstablishmentCompetency.objects.create(
establishment_id=establishment_id,
competency=None,
custom_name=nom,
custom_category=category,
is_required=False
)
created.append({
"competence_id": ec.id,
"nom": ec.custom_name,
"state": "custom"
})
except Exception as e:
errors.append({"error": str(e), "item": item})
status_code = status.HTTP_201_CREATED if created else status.HTTP_400_BAD_REQUEST
return JsonResponse({
"created": created,
"errors": errors
}, status=status_code, safe=False)
def delete(self, request):
"""
Supprime une ou plusieurs compétences custom (EstablishmentCompetency) à partir d'une liste d'IDs.
Attendu dans le body :
{
"ids": [1, 2, 3, ...]
}
"""
data = JSONParser().parse(request)
ids = data.get("ids", [])
deleted = []
errors = []
for ec_id in ids:
try:
ec = EstablishmentCompetency.objects.get(id=ec_id)
ec.delete()
deleted.append(ec_id)
except EstablishmentCompetency.DoesNotExist:
errors.append({"id": ec_id, "error": "No object found"})
return JsonResponse({
"deleted": deleted,
"errors": errors
}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyDetailView(APIView):
def get(self, request, id):
try:
ec = EstablishmentCompetency.objects.get(id=id)
serializer = EstablishmentCompetencySerializer(ec)
return JsonResponse(serializer.data, safe=False)
except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
ec = EstablishmentCompetency.objects.get(id=id)
except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = EstablishmentCompetencySerializer(ec, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
ec = EstablishmentCompetency.objects.get(id=id)
ec.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)

View File

@ -557,13 +557,13 @@ export default function Page() {
/>
{/* Popups */}
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={confirmPopupVisible}
isOpen={confirmPopupVisible}
message={confirmPopupMessage}
onConfirm={confirmPopupOnConfirm}
onCancel={() => setConfirmPopupVisible(false)}

View File

@ -182,7 +182,7 @@ export default function Layout({ children }) {
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
<Popup
visible={isPopupVisible}
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}

View File

@ -548,7 +548,7 @@ export default function Page() {
{/* Popup */}
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}

View File

@ -22,6 +22,7 @@ import {
fetchTuitionPaymentPlans,
fetchRegistrationPaymentModes,
fetchTuitionPaymentModes,
fetchEstablishmentCompetencies,
} from '@/app/actions/schoolAction';
import { fetchProfiles } from '@/app/actions/authAction';
import SidebarTabs from '@/components/SidebarTabs';
@ -30,6 +31,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
export default function Page() {
const [specialities, setSpecialities] = useState([]);
@ -45,6 +47,9 @@ export default function Page() {
const [registrationPaymentModes, setRegistrationPaymentModes] = useState([]);
const [tuitionPaymentModes, setTuitionPaymentModes] = useState([]);
const [profiles, setProfiles] = useState([]);
const [establishmentCompetencies, setEstablishmentCompetencies] = useState(
[]
);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
@ -98,9 +103,22 @@ export default function Page() {
.catch((error) => {
logger.error('Error fetching profileRoles:', error);
});
// Fetch data for establishment competencies
handleEstablishmentCompetencies();
}
}, [selectedEstablishmentId]);
const handleEstablishmentCompetencies = (cycle = 1) => {
fetchEstablishmentCompetencies(selectedEstablishmentId, cycle)
.then((data) => {
setEstablishmentCompetencies(data);
})
.catch((error) =>
logger.error('Error fetching setEstablishmentCompetencies:', error)
);
};
const handleSpecialities = () => {
fetchSpecialities(selectedEstablishmentId)
.then((data) => {
@ -339,6 +357,18 @@ export default function Page() {
</div>
),
},
{
id: 'Competencies',
label: 'Compétences',
content: (
<div className="h-full overflow-y-auto p-4">
<CompetenciesList
establishmentCompetencies={establishmentCompetencies}
onChangeCycle={handleEstablishmentCompetencies}
/>
</div>
),
},
];
return (

View File

@ -837,13 +837,13 @@ export default function Page({ params: { locale } }) {
) : null}
</div>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={confirmPopupVisible}
isOpen={confirmPopupVisible}
message={confirmPopupMessage}
onConfirm={confirmPopupOnConfirm}
onCancel={() => setConfirmPopupVisible(false)}

View File

@ -113,7 +113,7 @@ export default function Page() {
</div>
</div>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={popupConfirmAction}
onCancel={() => setPopupVisible(false)}

View File

@ -8,9 +8,46 @@ import {
BE_SCHOOL_PAYMENT_PLANS_URL,
BE_SCHOOL_PAYMENT_MODES_URL,
BE_SCHOOL_ESTABLISHMENT_URL,
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ ids }),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createEstablishmentCompetencies = (newData, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
return fetch(
`${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchSpecialities = (establishment) => {
return fetch(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`

View File

@ -9,8 +9,6 @@ const CheckBox = ({
itemLabelFunc = () => null,
horizontal,
}) => {
logger.debug(formData);
// Vérifier si formData[fieldName] est un tableau ou une valeur booléenne
const isChecked = Array.isArray(formData[fieldName])
? formData[fieldName].includes(parseInt(item.id)) // Si c'est un tableau, vérifier si l'élément est inclus

View File

@ -84,7 +84,7 @@ const DateTab = ({
</div>
{popupVisible && (
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}

View File

@ -248,14 +248,14 @@ export default function FilesToUpload({
</div>
)}
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -1,12 +1,6 @@
import * as Dialog from '@radix-ui/react-dialog';
const Modal = ({
isOpen,
setIsOpen,
title,
children,
modalClassName,
}) => {
const Modal = ({ isOpen, setIsOpen, title, children, modalClassName }) => {
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Portal>

View File

@ -330,7 +330,7 @@ const PaymentPlanSelector = ({
)}
</div>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}

View File

@ -0,0 +1,310 @@
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/SelectChoice';
import CheckBox from '@/components/CheckBox';
import Button from '@/components/Button';
import { useEstablishment } from '@/context/EstablishmentContext';
import {
fetchEstablishmentCompetencies,
createEstablishmentCompetencies,
deleteEstablishmentCompetencies,
} from '@/app/actions/schoolAction';
import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext';
const cycles = [
{ id: 1, label: 'Cycle 1' },
{ id: 2, label: 'Cycle 2' },
{ id: 3, label: 'Cycle 3' },
{ id: 4, label: 'Cycle 4' },
];
export default function CompetenciesList({
establishmentCompetencies,
onChangeCycle,
}) {
const [selectedCycle, setSelectedCycle] = useState(cycles[0].id);
const [showSelectedOnlyByCycle, setShowSelectedOnlyByCycle] = useState({
1: true,
2: true,
3: true,
4: true,
});
const [expandAllByCycle, setExpandAllByCycle] = useState({
1: false,
2: false,
3: false,
4: false,
});
const [hasSelectionByCycle, setHasSelectionByCycle] = useState({
1: false,
2: false,
3: false,
4: false,
});
const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken();
const { showNotification } = useNotification();
// Référence vers le composant TreeView pour récupérer les compétences sélectionnées
const treeViewRef = useRef();
// Met à jour l'état de sélection à chaque changement dans TreeView
const handleSelectionChange = useCallback(
(selectedCompetencies) => {
setHasSelectionByCycle((prev) => ({
...prev,
[selectedCycle]: selectedCompetencies.length > 0,
}));
},
[selectedCycle]
);
// Filtrage : si showSelectedOnly, on affiche uniquement les compétences de l'établissement (state !== "none")
// sinon, on affiche toutes les compétences du cycle
const filteredData = (establishmentCompetencies.data || []).map(
(domaine) => ({
...domaine,
categories: domaine.categories.map((cat) => ({
...cat,
competences: showSelectedOnlyByCycle[selectedCycle]
? cat.competences.filter((c) => c.state !== 'none')
: cat.competences,
})),
})
);
const showSelectedOnly = showSelectedOnlyByCycle[selectedCycle];
const expandAll = expandAllByCycle[selectedCycle];
const handleShowSelectedOnlyChange = () => {
setShowSelectedOnlyByCycle((prev) => ({
...prev,
[selectedCycle]: !prev[selectedCycle],
}));
};
const handleExpandAllChange = () => {
setExpandAllByCycle((prev) => ({
...prev,
[selectedCycle]: !prev[selectedCycle],
}));
};
const handleCycleChange = (e) => {
const value = Number(e.target.value);
setSelectedCycle(value);
setHasSelectionByCycle((prev) => ({
...prev,
[value]: false,
}));
// Réinitialise la sélection visuelle dans le TreeView
if (treeViewRef.current && treeViewRef.current.clearSelection) {
treeViewRef.current.clearSelection();
}
onChangeCycle(value);
};
const handleSubmit = () => {
if (!treeViewRef.current || !treeViewRef.current.getSelectedCompetencies)
return;
const selectedIds = treeViewRef.current.getSelectedCompetencies();
const toCreate = [];
const toDelete = [];
const selectedCustomKeys = new Set(
(establishmentCompetencies.data || []).flatMap((domaine) =>
domaine.categories.flatMap((cat) =>
cat.competences
.filter(
(c) =>
c.state === 'custom' &&
(selectedIds.includes(String(c.competence_id)) ||
selectedIds.includes(Number(c.competence_id)))
)
.map((c) => `${cat.categorie_id}__${c.nom.trim().toLowerCase()}`)
)
)
);
(establishmentCompetencies.data || []).forEach((domaine) => {
domaine.categories.forEach((cat) => {
cat.competences.forEach((competence) => {
const isSelected =
selectedIds.includes(String(competence.competence_id)) ||
selectedIds.includes(Number(competence.competence_id));
const key = `${cat.categorie_id}__${competence.nom.trim().toLowerCase()}`;
// "none" sélectionné => à créer, sauf si une custom du même nom/catégorie est déjà sélectionnée
if (
competence.state === 'none' &&
isSelected &&
!selectedCustomKeys.has(key)
) {
toCreate.push({
category_id: cat.categorie_id,
establishment_id: selectedEstablishmentId,
nom: competence.nom,
});
} else if (competence.state === 'custom' && isSelected) {
// Suppression d'une compétence custom
toDelete.push({
competence_id: competence.competence_id, // id de EstablishmentCompetency
nom: competence.nom,
category_id: cat.categorie_id,
establishment_id: selectedEstablishmentId,
});
}
});
});
});
const afterSuccess = () => {
if (treeViewRef.current && treeViewRef.current.clearSelection) {
treeViewRef.current.clearSelection();
}
setHasSelectionByCycle((prev) => ({
...prev,
[selectedCycle]: false,
}));
onChangeCycle(selectedCycle);
showNotification('Opération effectuée avec succès', 'success', 'Succès');
};
if (toCreate.length > 0 && toDelete.length > 0) {
Promise.all([
createEstablishmentCompetencies(toCreate, csrfToken),
deleteEstablishmentCompetencies(
toDelete.map((item) => item.competence_id),
csrfToken
),
])
.then(afterSuccess)
.catch((error) => {
showNotification(
error.message ||
'Erreur apparue lors de la mise à jour des compétences',
'error',
'Erreur'
);
});
} else if (toCreate.length > 0) {
createEstablishmentCompetencies(toCreate, csrfToken)
.then(afterSuccess)
.catch((error) => {
showNotification(
error.message ||
'Erreur apparue lors de la mise à jour des compétences',
'error',
'Erreur'
);
});
} else if (toDelete.length > 0) {
deleteEstablishmentCompetencies(
toDelete.map((item) => item.competence_id),
csrfToken
)
.then(afterSuccess)
.catch((error) => {
showNotification(
error.message ||
'Erreur apparue lors de la mise à jour des compétences',
'error',
'Erreur'
);
});
}
};
const hasSelection = hasSelectionByCycle[selectedCycle];
return (
<div className="h-full flex flex-col">
<SectionHeader
icon={Award}
title="Liste des compétences"
description="Gérez les compétences par cycle"
/>
{/* Zone filtres centrée et plus large */}
<div className="mb-6 flex justify-center">
<div className="w-full max-w-3xl flex flex-col gap-4 p-6 rounded-lg border border-emerald-200 shadow-sm bg-white/80 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row sm:items-start gap-8">
{/* Select cycle */}
<div className="flex-1 min-w-[220px]">
<SelectChoice
name="cycle"
label="Cycle"
placeHolder="Sélectionnez un cycle"
choices={cycles.map((cycle) => ({
value: cycle.id,
label: cycle.label,
}))}
selected={selectedCycle}
callback={handleCycleChange}
/>
</div>
{/* Cases à cocher l'une sous l'autre */}
<div className="flex flex-col gap-4 min-w-[220px]">
<CheckBox
item={{ id: 'showSelectedOnly' }}
formData={{ showSelectedOnly }}
handleChange={handleShowSelectedOnlyChange}
fieldName="showSelectedOnly"
itemLabelFunc={() => 'Uniquement les compétences sélectionnées'}
horizontal={false}
/>
<CheckBox
item={{ id: 'expandAll' }}
formData={{ expandAll }}
handleChange={handleExpandAllChange}
fieldName="expandAll"
itemLabelFunc={() => 'Tout dérouler'}
horizontal={false}
/>
</div>
</div>
</div>
</div>
{/* Zone scrollable pour le TreeView */}
<div className="flex-1 min-h-0 overflow-y-auto">
<TreeView
ref={treeViewRef}
data={filteredData}
expandAll={expandAll}
onSelectionChange={handleSelectionChange}
/>
</div>
{/* Bouton submit centré en bas */}
<div className="flex justify-center mb-2 mt-6">
<Button
text="Sauvegarder"
className={`px-6 py-2 rounded-md shadow ${
!hasSelection
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
onClick={handleSubmit}
primary
disabled={!hasSelection}
/>
</div>
{/* Légende en dessous du bouton, alignée à gauche */}
<div className="flex flex-row items-center gap-4 mb-4">
<span className="flex items-center gap-2 text-emerald-700 font-bold">
<CheckCircle className="w-4 h-4 text-emerald-500" />
Compétence requise
</span>
<span className="flex items-center gap-2 text-emerald-600 font-semibold">
Compétence sélectionnée ou créée
</span>
<span className="flex items-center gap-2 text-gray-500">
Compétence ignorée
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,144 @@
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from 'react';
import { CheckCircle, Circle } from 'lucide-react';
const TreeView = forwardRef(function TreeView(
{ data, expandAll, onSelectionChange },
ref
) {
const [openDomains, setOpenDomains] = useState({});
const [openCategories, setOpenCategories] = useState({});
const [selectedCompetencies, setSelectedCompetencies] = useState({}); // { [competence_id]: true }
// N'ouvre ou ne ferme tout que si expandAll change explicitement
useEffect(() => {
if (!data) return;
const allDomains = {};
const allCategories = {};
if (expandAll) {
data.forEach((domaine) => {
allDomains[domaine.domaine_id] = true;
domaine.categories.forEach((cat) => {
allCategories[cat.categorie_id] = true;
});
});
setOpenDomains(allDomains);
setOpenCategories(allCategories);
} else {
// On ne ferme tout que si l'utilisateur décoche explicitement "Tout dérouler"
setOpenDomains({});
setOpenCategories({});
}
}, [expandAll]); // <-- uniquement expandAll
// Appelle le callback à chaque changement de sélection
useEffect(() => {
if (onSelectionChange) {
const selected = Object.entries(selectedCompetencies)
.filter(([_, selected]) => selected)
.map(([id]) => id);
onSelectionChange(selected);
}
}, [selectedCompetencies, onSelectionChange]);
const toggleDomain = (id) =>
setOpenDomains((prev) => ({ ...prev, [id]: !prev[id] }));
const toggleCategory = (id) =>
setOpenCategories((prev) => ({ ...prev, [id]: !prev[id] }));
const handleCompetenceClick = (competence) => {
if (competence.state === 'required') return;
setSelectedCompetencies((prev) => {
const next = {
...prev,
[competence.competence_id]: !prev[competence.competence_id],
};
console.log(competence);
return next;
});
};
// Pour exposer la sélection au parent
useImperativeHandle(ref, () => ({
getSelectedCompetencies: () => {
const selected = Object.entries(selectedCompetencies)
.filter(([_, selected]) => selected)
.map(([id]) => id);
return selected;
},
clearSelection: () => setSelectedCompetencies({}),
}));
return (
<div>
{data.map((domaine) => (
<div key={domaine.domaine_id} className="mb-4">
<button
className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800"
onClick={() => toggleDomain(domaine.domaine_id)}
>
{openDomains[domaine.domaine_id] ? '▼' : '►'} {domaine.domaine_nom}
</button>
{openDomains[domaine.domaine_id] && (
<div className="ml-4">
{domaine.categories.map((categorie) => (
<div key={categorie.categorie_id} className="mb-2">
<button
className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700"
onClick={() => toggleCategory(categorie.categorie_id)}
>
{openCategories[categorie.categorie_id] ? '▼' : '►'}
{categorie.categorie_nom}
</button>
{openCategories[categorie.categorie_id] && (
<ul className="ml-4">
{categorie.competences.map((competence) => {
const isSelected =
selectedCompetencies[competence.competence_id];
return (
<li
key={competence.competence_id}
className={`py-1 flex items-center gap-2 ${
competence.state === 'required'
? 'text-emerald-700 font-bold'
: competence.state === 'custom'
? isSelected
? 'text-gray-500 cursor-pointer hover:text-emerald-600'
: 'text-emerald-600 font-semibold cursor-pointer'
: isSelected
? 'text-emerald-600 font-semibold cursor-pointer'
: 'text-gray-500 cursor-pointer hover:text-emerald-600'
}`}
onClick={() => handleCompetenceClick(competence)}
style={{
cursor:
competence.state === 'required'
? 'default'
: 'pointer',
userSelect: 'none',
}}
>
{competence.state === 'required' && (
<CheckCircle className="w-4 h-4 text-emerald-500" />
)}
{competence.nom}
</li>
);
})}
</ul>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
);
});
export default TreeView;

View File

@ -469,7 +469,7 @@ const ClassesSection = ({
onClick={() => {
setRemovePopupVisible(true);
setRemovePopupMessage(
'Attentions ! \nVous êtes sur le point de supprimer la classe ' +
'Attention ! \nVous êtes sur le point de supprimer la classe ' +
classe.atmosphere_name +
".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
);
@ -554,14 +554,14 @@ const ClassesSection = ({
}
/>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -197,7 +197,7 @@ const SpecialitiesSection = ({
onClick={() => {
setRemovePopupVisible(true);
setRemovePopupMessage(
'Attentions ! \nVous êtes sur le point de supprimer la spécialité ' +
'Attention ! \nVous êtes sur le point de supprimer la spécialité ' +
speciality.name +
".\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
);
@ -265,14 +265,14 @@ const SpecialitiesSection = ({
}
/>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -512,7 +512,7 @@ const TeachersSection = ({
onClick={() => {
setRemovePopupVisible(true);
setRemovePopupMessage(
"Attentions ! \nVous êtes sur le point de supprimer l'enseignant " +
"Attention ! \nVous êtes sur le point de supprimer l'enseignant " +
teacher.last_name +
' ' +
teacher.first_name +
@ -589,14 +589,14 @@ const TeachersSection = ({
}
/>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -155,7 +155,7 @@ export default function FileUploadDocuSeal({
return (
<div className="h-full flex flex-col mt-4 space-y-6">
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}

View File

@ -592,7 +592,7 @@ export default function FilesGroupsManagement({
handleDelete={handleDelete}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -348,7 +348,7 @@ export default function ParentFilesSection({
}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -384,14 +384,14 @@ const DiscountsSection = ({
emptyMessage={emptyMessage}
/>
<Popup
visible={popupVisible}
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
visible={removePopupVisible}
isOpen={removePopupVisible}
message={removePopupMessage}
onConfirm={removePopupOnConfirm}
onCancel={() => setRemovePopupVisible(false)}

View File

@ -42,6 +42,7 @@ export const BE_SCHOOL_FEES_URL = `${BASE_URL}/School/fees`;
export const BE_SCHOOL_DISCOUNTS_URL = `${BASE_URL}/School/discounts`;
export const BE_SCHOOL_PAYMENT_PLANS_URL = `${BASE_URL}/School/paymentPlans`;
export const BE_SCHOOL_PAYMENT_MODES_URL = `${BASE_URL}/School/paymentModes`;
export const BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL = `${BASE_URL}/School/establishmentCompetencies`;
// ESTABLISHMENT
export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishments`;