From 58144ba0d0f1b53e9313f4cd4d3fbc3e6bfdd274 Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sat, 3 May 2025 15:12:17 +0200 Subject: [PATCH] feat: Gestion du planning [3] --- Back-End/Planning/models.py | 17 +- Back-End/Planning/urls.py | 2 +- Back-End/Planning/views.py | 84 ++++- Back-End/School/views.py | 46 +-- Front-End/src/app/[locale]/admin/layout.js | 94 +++--- Front-End/src/app/[locale]/admin/page.js | 2 +- .../src/app/[locale]/admin/planning/page.js | 23 +- .../src/app/[locale]/admin/structure/page.js | 23 +- Front-End/src/app/actions/planningAction.js | 38 ++- Front-End/src/app/layout.js | 2 +- .../src/components/{ => Calendar}/Calendar.js | 2 +- .../components/{ => Calendar}/EventModal.js | 83 ++--- .../components/Calendar/ScheduleNavigation.js | 249 +++++++++++++++ Front-End/src/components/Calendar/WeekView.js | 27 +- Front-End/src/components/Footer.js | 2 +- .../src/components/ScheduleNavigation.js | 193 ----------- Front-End/src/components/SidebarTabs.js | 10 +- .../Structure/Configuration/ClassesSection.js | 55 ++-- .../Structure/Configuration/DateRange.js | 47 --- .../Configuration/PlanningConfiguration.js | 121 ------- .../Configuration/StructureManagement.js | 2 +- .../Structure/Files/FilesGroupsManagement.js | 2 +- .../Structure/Planning/ClassesInformation.js | 40 --- .../Structure/Planning/ClassesList.js | 89 ------ .../Structure/Planning/DraggableSpeciality.js | 40 --- .../Structure/Planning/DropTargetCell.js | 87 ----- .../Structure/Planning/PlanningClassView.js | 274 ---------------- .../Structure/Planning/ScheduleEventModal.js | 299 ++++++++++++++++++ .../Structure/Planning/ScheduleManagement.js | 257 ++++----------- .../Structure/Planning/SpecialitiesList.js | 19 -- .../Planning/SpecialityEventModal.js | 258 --------------- .../Structure/Tarification/FeesManagement.js | 2 +- Front-End/src/context/ClasseFormContext.js | 71 ----- Front-End/src/context/ClassesContext.js | 93 +----- Front-End/src/context/PlanningContext.js | 76 +++-- .../src/context/SpecialityFormContext.js | 18 -- Front-End/src/context/TeacherFormContext.js | 25 -- Front-End/src/css/tailwind.css | 1 + Front-End/src/utils/constants.js | 30 -- 39 files changed, 939 insertions(+), 1864 deletions(-) rename Front-End/src/components/{ => Calendar}/Calendar.js (99%) rename Front-End/src/components/{ => Calendar}/EventModal.js (80%) create mode 100644 Front-End/src/components/Calendar/ScheduleNavigation.js delete mode 100644 Front-End/src/components/ScheduleNavigation.js delete mode 100644 Front-End/src/components/Structure/Configuration/DateRange.js delete mode 100644 Front-End/src/components/Structure/Configuration/PlanningConfiguration.js delete mode 100644 Front-End/src/components/Structure/Planning/ClassesInformation.js delete mode 100644 Front-End/src/components/Structure/Planning/ClassesList.js delete mode 100644 Front-End/src/components/Structure/Planning/DraggableSpeciality.js delete mode 100644 Front-End/src/components/Structure/Planning/DropTargetCell.js delete mode 100644 Front-End/src/components/Structure/Planning/PlanningClassView.js create mode 100644 Front-End/src/components/Structure/Planning/ScheduleEventModal.js delete mode 100644 Front-End/src/components/Structure/Planning/SpecialitiesList.js delete mode 100644 Front-End/src/components/Structure/Planning/SpecialityEventModal.js delete mode 100644 Front-End/src/context/ClasseFormContext.js delete mode 100644 Front-End/src/context/SpecialityFormContext.js delete mode 100644 Front-End/src/context/TeacherFormContext.js delete mode 100644 Front-End/src/utils/constants.js diff --git a/Back-End/Planning/models.py b/Back-End/Planning/models.py index b36882c..78d2ce8 100644 --- a/Back-End/Planning/models.py +++ b/Back-End/Planning/models.py @@ -2,6 +2,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.translation import gettext_lazy as _ from django.conf import settings +from School.models import SchoolClass from Establishment.models import Establishment @@ -14,25 +15,33 @@ class RecursionType(models.IntegerChoices): class Planning(models.Model): establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT) + school_class = models.ForeignKey( + SchoolClass, + on_delete=models.CASCADE, + related_name="planning", + null=True, # Permet des valeurs nulles + blank=True # Rend le champ facultatif dans les formulaires + ) name = models.CharField(max_length=255) description = models.TextField(default="", blank=True, null=True) color= models.CharField(max_length=255, default="#000000") def __str__(self): - return f'Planning for {self.user.username}' + return f'Planning {self.name}' class Events(models.Model): - planning = models.ForeignKey(Planning, on_delete=models.PROTECT) + planning = models.ForeignKey(Planning, on_delete=models.CASCADE) title = models.CharField(max_length=255) - description = models.TextField() + description = models.TextField(default="", blank=True, null=True) start = models.DateTimeField() end = models.DateTimeField() recursionType = models.IntegerField(choices=RecursionType, default=0) + recursionEnd = models.DateTimeField(default=None, blank=True, null=True) color= models.CharField(max_length=255) location = models.CharField(max_length=255, default="", blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f'Event for {self.user.username}' \ No newline at end of file + return f'Event {self.title}' \ No newline at end of file diff --git a/Back-End/Planning/urls.py b/Back-End/Planning/urls.py index a2ef0c2..62143bb 100644 --- a/Back-End/Planning/urls.py +++ b/Back-End/Planning/urls.py @@ -7,5 +7,5 @@ urlpatterns = [ re_path(r'^plannings/(?P[0-9]+)$', PlanningWithIdView.as_view(), name="planning"), re_path(r'^events$', EventsView.as_view(), name="events"), re_path(r'^events/(?P[0-9]+)$', EventsWithIdView.as_view(), name="events"), - re_path(r'^events/upcoming', UpcomingEventsView.as_view(), name="events"), + re_path(r'^events/upcoming', UpcomingEventsView.as_view(), name="events"), ] \ No newline at end of file diff --git a/Back-End/Planning/views.py b/Back-End/Planning/views.py index e3fb852..8faa19e 100644 --- a/Back-End/Planning/views.py +++ b/Back-End/Planning/views.py @@ -1,18 +1,32 @@ from django.http.response import JsonResponse from rest_framework.views import APIView from django.utils import timezone +from dateutil.relativedelta import relativedelta -from .models import Planning, Events +from .models import Planning, Events, RecursionType from .serializers import PlanningSerializer, EventsSerializer from N3wtSchool import bdd + class PlanningView(APIView): def get(self, request): - plannings=bdd.getAllObjects(Planning) - planning_serializer=PlanningSerializer(plannings, many=True) + establishment_id = request.GET.get('establishment_id', None) + planning_mode = request.GET.get('planning_mode', None) + plannings = bdd.getAllObjects(Planning) + + if establishment_id is not None: + plannings = plannings.filter(establishment=establishment_id) + + # Filtrer en fonction du planning_mode + if planning_mode == "classSchedule": + plannings = plannings.filter(school_class__isnull=False) + elif planning_mode == "planning": + plannings = plannings.filter(school_class__isnull=True) + + planning_serializer = PlanningSerializer(plannings.distinct(), many=True) return JsonResponse(planning_serializer.data, safe=False) def post(self, request): @@ -56,17 +70,63 @@ class PlanningWithIdView(APIView): class EventsView(APIView): def get(self, request): - events = bdd.getAllObjects(Events) + establishment_id = request.GET.get('establishment_id', None) + planning_mode = request.GET.get('planning_mode', None) + filterParams = {} + plannings=[] + events = Events.objects.all() + if establishment_id is not None : + filterParams['establishment'] = establishment_id + if planning_mode is not None: + filterParams['school_class__isnull'] = (planning_mode!="classSchedule") + if filterParams: + plannings = Planning.objects.filter(**filterParams) + events = Events.objects.filter(planning__in=plannings) events_serializer = EventsSerializer(events, many=True) return JsonResponse(events_serializer.data, safe=False) def post(self, request): events_serializer = EventsSerializer(data=request.data) if events_serializer.is_valid(): - events_serializer.save() + event = events_serializer.save() + + # Gérer les événements récurrents + if event.recursionType != RecursionType.RECURSION_NONE: + self.create_recurring_events(event) + return JsonResponse(events_serializer.data, status=201) return JsonResponse(events_serializer.errors, status=400) + def create_recurring_events(self, event): + current_start = event.start + current_end = event.end + + while current_start < event.recursionEnd: + if event.recursionType == RecursionType.RECURSION_DAILY: + current_start += relativedelta(days=1) + current_end += relativedelta(days=1) + elif event.recursionType == RecursionType.RECURSION_WEEKLY: + current_start += relativedelta(weeks=1) + current_end += relativedelta(weeks=1) + elif event.recursionType == RecursionType.RECURSION_MONTHLY: + current_start += relativedelta(months=1) + current_end += relativedelta(months=1) + else: + break # Pour d'autres types de récurrence non gérés + + # Créer une nouvelle occurrence + Events.objects.create( + planning=event.planning, + title=event.title, + description=event.description, + start=current_start, + end=current_end, + recursionEnd=event.recursionEnd, + recursionType=event.recursionType, # Les occurrences ne sont pas récurrentes + color=event.color, + location=event.location, + ) + class EventsWithIdView(APIView): def put(self, request, id): try: @@ -92,6 +152,18 @@ class EventsWithIdView(APIView): class UpcomingEventsView(APIView): def get(self, request): current_date = timezone.now() - upcoming_events = Events.objects.filter(start__gte=current_date) + establishment_id = request.GET.get('establishment_id', None) + + if establishment_id is not None: + # Filtrer les plannings par establishment_id et sans school_class + plannings = Planning.objects.filter(establishment=establishment_id, school_class__isnull=True) + # Filtrer les événements associés à ces plannings et qui sont à venir + upcoming_events = Events.objects.filter(planning__in=plannings, start__gte=current_date) + else: + # Récupérer tous les événements à venir si aucun establishment_id n'est fourni + # et les plannings ne doivent pas être rattachés à une school_class + plannings = Planning.objects.filter(school_class__isnull=True) + upcoming_events = Events.objects.filter(planning__in=plannings, start__gte=current_date) + events_serializer = EventsSerializer(upcoming_events, many=True) return JsonResponse(events_serializer.data, safe=False) \ No newline at end of file diff --git a/Back-End/School/views.py b/Back-End/School/views.py index c80854a..b9934e9 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -5,24 +5,24 @@ from rest_framework.parsers import JSONParser from rest_framework.views import APIView from rest_framework import status from .models import ( - Teacher, - Speciality, - SchoolClass, - Planning, - Discount, - Fee, - PaymentPlan, + Teacher, + Speciality, + SchoolClass, + Planning, + Discount, + Fee, + PaymentPlan, PaymentMode, AbsenceManagement ) from .serializers import ( - TeacherSerializer, - SpecialitySerializer, - SchoolClassSerializer, - PlanningSerializer, - DiscountSerializer, - FeeSerializer, - PaymentPlanSerializer, + TeacherSerializer, + SpecialitySerializer, + SchoolClassSerializer, + PlanningSerializer, + DiscountSerializer, + FeeSerializer, + PaymentPlanSerializer, PaymentModeSerializer, AbsenceManagementSerializer ) @@ -93,8 +93,8 @@ class TeacherListCreateView(APIView): if teacher_serializer.is_valid(): teacher_serializer.save() - - return JsonResponse(teacher_serializer.data, safe=False) + + return JsonResponse(teacher_serializer.data, safe=False) return JsonResponse(teacher_serializer.errors, safe=False) @@ -139,7 +139,7 @@ class SchoolClassListCreateView(APIView): classe_serializer = SchoolClassSerializer(data=classe_data) if classe_serializer.is_valid(): - classe_serializer.save() + classe_serializer.save() return JsonResponse(classe_serializer.data, safe=False) return JsonResponse(classe_serializer.errors, safe=False) @@ -195,7 +195,7 @@ class PlanningDetailView(APIView): def put(self, request, id): planning_data = JSONParser().parse(request) - + try: planning = Planning.objects.get(id=id) except Planning.DoesNotExist: @@ -210,7 +210,7 @@ class PlanningDetailView(APIView): return JsonResponse(planning_serializer.data, safe=False) return JsonResponse(planning_serializer.errors, safe=False) - + def delete(self, request, id): return delete_object(Planning, id) @@ -227,7 +227,7 @@ class FeeListCreateView(APIView): fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() fee_serializer = FeeSerializer(fees, many=True) - + return JsonResponse(fee_serializer.data, safe=False, status=status.HTTP_200_OK) def post(self, request): @@ -277,7 +277,7 @@ class DiscountListCreateView(APIView): discounts = Discount.objects.filter(type=discount_type_value, establishment_id=establishment_id).distinct() discounts_serializer = DiscountSerializer(discounts, many=True) - + return JsonResponse(discounts_serializer.data, safe=False, status=status.HTTP_200_OK) def post(self, request): @@ -327,7 +327,7 @@ class PaymentPlanListCreateView(APIView): payment_plans = PaymentPlan.objects.filter(type=type_value, establishment_id=establishment_id).distinct() payment_plans_serializer = PaymentPlanSerializer(payment_plans, many=True) - + return JsonResponse(payment_plans_serializer.data, safe=False, status=status.HTTP_200_OK) def post(self, request): @@ -377,7 +377,7 @@ class PaymentModeListCreateView(APIView): payment_modes = PaymentMode.objects.filter(type=type_value, establishment_id=establishment_id).distinct() payment_modes_serializer = PaymentModeSerializer(payment_modes, many=True) - + return JsonResponse(payment_modes_serializer.data, safe=False, status=status.HTTP_200_OK) def post(self, request): diff --git a/Front-End/src/app/[locale]/admin/layout.js b/Front-End/src/app/[locale]/admin/layout.js index 01dc897..1232a70 100644 --- a/Front-End/src/app/[locale]/admin/layout.js +++ b/Front-End/src/app/[locale]/admin/layout.js @@ -43,7 +43,6 @@ export default function Layout({ children }) { const { profileRole, establishments, user, clearContext } = useEstablishment(); - // Déplacer le reste du code ici... const sidebarItems = { admin: { id: 'admin', @@ -144,12 +143,40 @@ export default function Layout({ children }) { return ( -
- {/* Retirer la condition !isLoading car on gère déjà le chargement au début */} - {/* Sidebar avec hauteur forcée */} + + {/* Topbar */} +
+
+ +
{headerTitle}
+
+ + } + items={dropdownItems} + buttonClassName="" + menuClassName="absolute right-0 mt-2 w-64 bg-white border border-gray-200 rounded shadow-lg" + /> +
+ + {/* Sidebar */}
- {/* Overlay pour fermer la sidebar en cliquant à l'extérieur sur mobile */} + {/* Overlay for mobile */} {isSidebarOpen && (
)} -
- {/* Header responsive */} -
-
- -
- {headerTitle} -
-
- - } - items={dropdownItems} - buttonClassName="" - menuClassName="absolute right-0 mt-2 w-64 bg-white border border-gray-200 rounded shadow-lg" - /> -
- {/* Main Content */} -
- {/* Content avec scroll si nécessaire */} -
{children}
- {/* Footer responsive */} -
-
+ {/* Main container */} +
+ {children}
-
+ + {/* Footer */} + +
+ + { setUpcomingEvents(data); }) diff --git a/Front-End/src/app/[locale]/admin/planning/page.js b/Front-End/src/app/[locale]/admin/planning/page.js index 8085ffd..b971b35 100644 --- a/Front-End/src/app/[locale]/admin/planning/page.js +++ b/Front-End/src/app/[locale]/admin/planning/page.js @@ -1,9 +1,10 @@ 'use client'; -import { PlanningProvider } from '@/context/PlanningContext'; -import Calendar from '@/components/Calendar'; -import EventModal from '@/components/EventModal'; -import ScheduleNavigation from '@/components/ScheduleNavigation'; +import { PlanningModes, PlanningProvider, RecurrenceType } from '@/context/PlanningContext'; +import Calendar from '@/components/Calendar/Calendar'; +import EventModal from '@/components/Calendar/EventModal'; +import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation'; import { useState } from 'react'; +import { useEstablishment } from '@/context/EstablishmentContext'; export default function Page() { const [isModalOpen, setIsModalOpen] = useState(false); @@ -14,13 +15,14 @@ export default function Page() { end: '', location: '', planning: '', // Enlever la valeur par défaut ici - recurrence: 'none', + recursionType: RecurrenceType.NONE, selectedDays: [], - recurrenceEnd: '', + recursionEnd: '', customInterval: 1, customUnit: 'days', viewType: 'week', // Ajouter la vue semaine par défaut }); + const { selectedEstablishmentId } = useEstablishment(); const initializeNewEvent = (date = new Date()) => { // S'assurer que date est un objet Date valide @@ -33,9 +35,11 @@ export default function Page() { end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(), location: '', planning: '', // Ne pas définir de valeur par défaut ici non plus - recurrence: 'none', + recursionType: RecurrenceType.NONE, selectedDays: [], - recurrenceEnd: '', + recursionEnd: new Date( + eventDate.getTime() + 2 * 60 * 60 * 1000 + ).toISOString(), customInterval: 1, customUnit: 'days', }); @@ -43,7 +47,8 @@ export default function Page() { }; return ( - + + {/*
*/}
logger.error('Error fetching classes:', error)); }; - const handleSchedules = () => { - fetchSchedules() - .then((data) => { - setSchedules(data); - }) - .catch((error) => logger.error('Error fetching schedules:', error)); - }; const handleRegistrationDiscounts = () => { fetchRegistrationDiscounts(selectedEstablishmentId) @@ -299,6 +290,8 @@ export default function Page() { ), @@ -343,12 +336,12 @@ export default function Page() { ]; return ( -
+ <> + + + + -
- -
-
); } diff --git a/Front-End/src/app/actions/planningAction.js b/Front-End/src/app/actions/planningAction.js index a5a18f0..48c10db 100644 --- a/Front-End/src/app/actions/planningAction.js +++ b/Front-End/src/app/actions/planningAction.js @@ -2,12 +2,14 @@ import { BE_PLANNING_PLANNINGS_URL, BE_PLANNING_EVENTS_URL } from '@/utils/Url'; const requestResponseHandler = async (response) => { const body = response.status !== 204 ? await response?.json() : {}; - console.log(response); if (response.ok) { return body; } // Throw an error with the JSON body containing the form errors - const error = new Error(body?.errorMessage || 'Une erreur est survenue'); + const error = new Error( + body?.errorMessage || + `Une erreur est survenue code de retour : ${response.status}` + ); error.details = body; throw error; }; @@ -51,8 +53,15 @@ const removeDatas = (url, csrfToken) => { }).then(requestResponseHandler); }; -export const fetchPlannings = () => { - return getData(`${BE_PLANNING_PLANNINGS_URL}`); +export const fetchPlannings = (establishment_id=null,planningMode=null) => { + let url = `${BE_PLANNING_PLANNINGS_URL}`; + if (establishment_id) { + url += `?establishment_id=${establishment_id}`; + } + if (planningMode) { + url += `&planning_mode=${planningMode}`; + } + return getData(url); }; export const getPlanning = (id) => { @@ -71,8 +80,17 @@ export const deletePlanning = (id, csrfToken) => { return removeDatas(`${BE_PLANNING_PLANNINGS_URL}/${id}`, csrfToken); }; -export const fetchEvents = () => { - return getData(`${BE_PLANNING_EVENTS_URL}`); +export const fetchEvents = (establishment_id=null, planningMode=null) => { + let url = `${BE_PLANNING_EVENTS_URL}`; + if (establishment_id) { + url += `?establishment_id=${establishment_id}`; + } + if (planningMode) { + url += `&planning_mode=${planningMode}`; + } + + return getData(url); + }; export const getEvent = (id) => { @@ -91,6 +109,10 @@ export const deleteEvent = (id, csrfToken) => { return removeDatas(`${BE_PLANNING_EVENTS_URL}/${id}`, csrfToken); }; -export const fetchUpcomingEvents = () => { - return getData(`${BE_PLANNING_EVENTS_URL}/upcoming`); +export const fetchUpcomingEvents = (establishment_id=null) => { + let url = `${BE_PLANNING_EVENTS_URL}/upcoming`; + if (establishment_id) { + url += `?establishment_id=${establishment_id}`; + } + return getData(`${url}`); }; diff --git a/Front-End/src/app/layout.js b/Front-End/src/app/layout.js index cf8feea..e6d1c4c 100644 --- a/Front-End/src/app/layout.js +++ b/Front-End/src/app/layout.js @@ -28,7 +28,7 @@ export default async function RootLayout({ children, params }) { return ( - + {children} diff --git a/Front-End/src/components/Calendar.js b/Front-End/src/components/Calendar/Calendar.js similarity index 99% rename from Front-End/src/components/Calendar.js rename to Front-End/src/components/Calendar/Calendar.js index 13ed929..4bc2a31 100644 --- a/Front-End/src/components/Calendar.js +++ b/Front-End/src/components/Calendar/Calendar.js @@ -22,7 +22,7 @@ import { fr } from 'date-fns/locale'; import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import import logger from '@/utils/logger'; -const Calendar = ({ onDateClick, onEventClick }) => { +const Calendar = ({ modeSet, onDateClick, onEventClick }) => { const { currentDate, setCurrentDate, diff --git a/Front-End/src/components/EventModal.js b/Front-End/src/components/Calendar/EventModal.js similarity index 80% rename from Front-End/src/components/EventModal.js rename to Front-End/src/components/Calendar/EventModal.js index 308dbd8..391d625 100644 --- a/Front-End/src/components/EventModal.js +++ b/Front-End/src/components/Calendar/EventModal.js @@ -1,4 +1,4 @@ -import { usePlanning } from '@/context/PlanningContext'; +import { usePlanning, RecurrenceType } from '@/context/PlanningContext'; import { format } from 'date-fns'; import React from 'react'; @@ -13,23 +13,23 @@ export default function EventModal({ // S'assurer que planning est défini lors du premier rendu React.useEffect(() => { - if (!eventData.planning && schedules.length > 0) { + if (!eventData?.planning && schedules.length > 0) { setEventData((prev) => ({ ...prev, planning: schedules[0].id, color: schedules[0].color, })); } - }, [schedules, eventData.planning]); + }, [schedules, eventData?.planning]); if (!isOpen) return null; const recurrenceOptions = [ - { value: 'none', label: 'Aucune' }, - { value: 'daily', label: 'Quotidienne' }, - { value: 'weekly', label: 'Hebdomadaire' }, - { value: 'monthly', label: 'Mensuelle' }, - { value: 'custom', label: 'Personnalisée' }, // Nouvelle option + { value: RecurrenceType.NONE, label: 'Aucune' }, + { value: RecurrenceType.DAILY, label: 'Quotidienne' }, + { value: RecurrenceType.WEEKLY, label: 'Hebdomadaire' }, + { value: RecurrenceType.MONTHLY, label: 'Mensuelle' }, + /* { value: RecurrenceType.CUSTOM, label: 'Personnalisée' }, */ ]; const daysOfWeek = [ @@ -171,10 +171,13 @@ export default function EventModal({ Récurrence - setEventData({ - ...eventData, - customInterval: parseInt(e.target.value) || 1, - }) - } - className="w-20 p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500" - /> - -
-
-
- )} - - {/* Jours de la semaine (pour récurrence hebdomadaire) */} - {eventData.recurrence === 'weekly' && ( + {eventData.recursionType == RecurrenceType.CUSTOM && (