diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx index 6af938d..f8751af 100644 --- a/frontend/src/pages/CalendarPage.jsx +++ b/frontend/src/pages/CalendarPage.jsx @@ -1,13 +1,8 @@ import React, { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, } from 'react'; @@ -17,13 +12,10 @@ import LoadingCard from '@/components/LoadingCard'; import MainCard from '@/components/MainCard'; import HoverInfo from '@/components/HoverInfo'; - import { DatePicker, TimePicker } from '@/components/ResponsivePickers'; import { - Card, - CardContent, } from '@/components/ui/card'; @@ -31,17 +23,11 @@ import { import { Button } from '@/components/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, } from '@/components/ui/dialog'; @@ -53,18 +39,13 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { - ChevronLeft, - ChevronRight, - Calendar, - Plus, - Pencil, - Trash2, + ArrowLeftRight, } from 'lucide-react'; @@ -73,57 +54,33 @@ import { eventApi } from '../api/event'; import { Alert, AlertDescription } from '@/components/ui/alert'; const MONTHS = [ - 'Janvier', - 'Février', - 'Mars', - 'Avril', - 'Mai', - 'Juin', - 'Juillet', - 'Août', - 'Septembre', - 'Octobre', - 'Novembre', - 'Décembre', ]; const DAYS_IN_MONTH = [ - 31, - 28, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, ]; @@ -145,3449 +102,1766 @@ const EVENT_LANE_GAP = 11; const EVENT_LEFT_PADDING = 5; const CalendarPage = () => { - const [requestError, setRequestError] = useState(''); - const [year, setYear] = useState( - new Date().getFullYear(), - ); + const [schoolView, setSchoolView] = useState(false); const [selectedMonth, setSelectedMonth] = useState( - new Date().getMonth(), - ); - const [isMobile, setIsMobile] = - useState(false); - const [availableHeight, setAvailableHeight] = - useState(600); - const [events, setEvents] = - useState([]); - const [loadingEvents, setLoadingEvents] = - useState(true); - const [hoveredEvent, setHoveredEvent] = - useState(null); - const [hoverPosition, setHoverPosition] = - useState(null); - /* - * ============================================================ - * MENU CONTEXTUEL - * ============================================================ - */ - const [contextMenu, setContextMenu] = - useState(null); - /* - * contextMenu : - * - * { - * x: number, - * y: number, - * type: 'day' | 'event', - * date?: 'YYYY-MM-DD', - * event?: object - * } - */ - /* - * ============================================================ - * DIALOG ÉVÉNEMENT - * ============================================================ - */ - const [eventDialogOpen, setEventDialogOpen] = - useState(false); - const [editingEvent, setEditingEvent] = - useState(null); - const [savingEvent, setSavingEvent] = - useState(false); - const [eventForm, setEventForm] = - useState({ - name: '', - description: '', - start_date: '', - end_date: '', - start_time: '', - end_time: '', - color: DEFAULT_EVENT_COLOR, - }); - const calendarRef = useRef(null); - /* SÉLECTION D'UNE PÉRIODE */ - const [selectionStart, setSelectionStart] = useState(null); - const [selectionEnd, setSelectionEnd] = useState(null); - const [isSelecting, setIsSelecting] = useState(false); - const selectionEndRef = useRef(null); - const isSelectingRef = useRef(false); - /* - * ============================================================ - * UTILITAIRES - * ============================================================ - */ - const isLeapYear = (value) => { - return ( - (value % 4 === 0 && - value % 100 !== 0) || - value % 400 === 0 - ); - }; - + const getMonthYear = (monthIndex) => + !isMobile && schoolView && monthIndex < 8 + ? year + 1 + : year; const getDaysInMonth = (monthIndex) => { - if ( - monthIndex === 1 && - - isLeapYear(year) - + isLeapYear(getMonthYear(monthIndex)) ) { - return 29; - } - return DAYS_IN_MONTH[monthIndex]; - }; - const formatDateForInput = ( - monthIndex, - day, - ) => { - - return `${year}-${String( - + return `${getMonthYear(monthIndex)}-${String( monthIndex + 1, - ).padStart(2, '0')}-${String(day).padStart( - 2, - '0', - )}`; - }; - const parseDate = (value) => { - if (!value) return null; - const [ - yearValue, - monthValue, - dayValue, - ] = String(value) - .split('-') - .map(Number); - if ( - !yearValue || - !monthValue || - !dayValue - ) { - return null; - } - return new Date( - yearValue, - monthValue - 1, - dayValue, - ); - }; - const getEventStartDate = ( - event, - ) => { - return event?.start_date ?? ''; - }; - const getEventEndDate = ( - event, - ) => { - return ( - event?.end_date ?? - event?.start_date ?? - '' - ); - }; - const isDateInEvent = ( - event, - monthIndex, - day, - ) => { - const eventStart = - parseDate( - getEventStartDate(event), - ); - const eventEnd = - parseDate( - getEventEndDate(event), - ); - if ( - !eventStart || - !eventEnd - ) { - return false; - } - const cellDate = new Date( - - year, - + getMonthYear(monthIndex), monthIndex, - day, - ); - return ( - cellDate >= eventStart && - cellDate <= eventEnd - ); - }; - const isSingleDayEvent = ( - event, - ) => { - return ( - getEventStartDate(event) === - getEventEndDate(event) - ); - }; - const getPeriodEventPosition = ( - event, - monthIndex, - day, - ) => { - const startDate = - parseDate( - getEventStartDate(event), - ); - const endDate = - parseDate( - getEventEndDate(event), - ); - if ( - !startDate || - !endDate - ) { - return null; - } - const currentDate = new Date( - - year, - + getMonthYear(monthIndex), monthIndex, - day, - ); - return { - isStart: - currentDate.getTime() === - startDate.getTime(), - isEnd: - currentDate.getTime() === - endDate.getTime(), - }; - }; - const getOrderedSelection = () => { - if (!selectionStart || !selectionEnd) return null; - return selectionStart <= selectionEnd - ? { start: selectionStart, end: selectionEnd } - : { start: selectionEnd, end: selectionStart }; - }; - const isDateSelected = (date) => { - - const selection = isMobile ? null : getOrderedSelection(); - + const selection = getOrderedSelection(); return Boolean( - selection && - date >= selection.start && - date <= selection.end, - ); - }; - const clearSelection = () => { - setSelectionStart(null); - setSelectionEnd(null); - setIsSelecting(false); - selectionEndRef.current = null; - isSelectingRef.current = false; - }; - const beginSelection = (pointerEvent, date) => { - - if (isMobile || pointerEvent.pointerType !== 'mouse' || pointerEvent.button !== 0) return; - + if (pointerEvent.button !== 0) return; pointerEvent.preventDefault(); - closeContextMenu(); - setHoveredEvent(null); - setHoverPosition(null); - selectionEndRef.current = date; - isSelectingRef.current = true; - setSelectionStart(date); - setSelectionEnd(date); - setIsSelecting(true); - }; - const updateSelectionFromPointer = (pointerEvent) => { - - if (!isSelectingRef.current || isMobile) return; - - const element = document.elementFromPoint( - - pointerEvent.clientX, - - pointerEvent.clientY, - - ); - - const cell = element?.closest?.('[data-calendar-date]'); - - const date = cell?.dataset?.calendarDate; - - if (!date || date === selectionEndRef.current) return; - - selectionEndRef.current = date; - - setSelectionEnd(date); - - }; - - const finishSelection = () => { - if (!isSelectingRef.current) return; - + const element = document.elementFromPoint( + pointerEvent.clientX, + pointerEvent.clientY, + ); + const cell = element?.closest?.('[data-calendar-date]'); + const date = cell?.dataset?.calendarDate; + if (!date || date === selectionEndRef.current) return; + selectionEndRef.current = date; + setSelectionEnd(date); + }; + const finishSelection = () => { + if (!isSelectingRef.current) return; isSelectingRef.current = false; - setIsSelecting(false); - }; - /* - * ============================================================ - * CHARGEMENT DES ÉVÉNEMENTS - * ============================================================ - */ - const loadEvents = async () => { - try { - setRequestError(''); - setLoadingEvents(true); - const data = - await eventApi.getAllEvents(); - const loadedEvents = - Array.isArray(data) - ? data - : data?.results ?? []; - setEvents(loadedEvents); - } catch (error) { - setRequestError(error.message); - console.error( - 'Erreur lors du chargement des événements :', - error, - ); - setEvents([]); - } finally { - setLoadingEvents(false); - } - }; - useEffect(() => { - loadEvents(); - }, []); - /* - * ============================================================ - * RESPONSIVE - * ============================================================ - */ - useEffect(() => { - const updateMobile = () => { - setIsMobile( - window.innerWidth < 768, - ); - }; - updateMobile(); - window.addEventListener( - 'resize', - updateMobile, - ); - return () => { - window.removeEventListener( - 'resize', - updateMobile, - ); - }; - }, []); - useEffect(() => { - const handlePointerMove = (pointerEvent) => { - updateSelectionFromPointer(pointerEvent); - }; - const handlePointerUp = () => { - finishSelection(); - }; - window.addEventListener('pointermove', handlePointerMove); - window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointercancel', handlePointerUp); - return () => { - window.removeEventListener('pointermove', handlePointerMove); - window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointercancel', handlePointerUp); - }; - }, []); - /* - * ============================================================ - * DIMENSIONS CALENDRIER - * ============================================================ - */ - useLayoutEffect(() => { - if (loadingEvents) return; - const updateDimensions = () => { - const calendar = - calendarRef.current; - if (!calendar) return; - const cardContent = - calendar.closest( - '[data-slot="card-content"]', - ); - if (!cardContent) return; - const calendarTop = - calendar.getBoundingClientRect() - .top; - const contentRect = - cardContent.getBoundingClientRect(); - const footer = - document.querySelector('footer'); - const footerTop = footer - ? footer.getBoundingClientRect() - .top - : window.innerHeight; - const bottom = Math.min( - contentRect.bottom, - footerTop, - window.innerHeight, - ); - const height = - bottom - calendarTop - 4; - setAvailableHeight( - Math.max(300, height), - ); - }; - updateDimensions(); - window.addEventListener( - 'resize', - updateDimensions, - ); - let observer = null; - if ( - typeof ResizeObserver !== - 'undefined' && - calendarRef.current - ) { - observer = new ResizeObserver( - updateDimensions, - ); - observer.observe( - calendarRef.current, - ); - } - return () => { - window.removeEventListener( - 'resize', - updateDimensions, - ); - observer?.disconnect(); - }; - }, [year, selectedMonth, isMobile, loadingEvents]); - /* - * ============================================================ - * ANNÉE - * ============================================================ - */ - const previousYear = () => { - setYear((current) => current - 1); - }; const nextYear = () => { - setYear((current) => current + 1); - }; const currentYear = () => { - setYear(new Date().getFullYear()); - }; const previousPeriod = () => { - if (!isMobile) { - previousYear(); - return; - } if (selectedMonth === 0) { - setSelectedMonth(11); - setYear((current) => current - 1); - return; - } setSelectedMonth((current) => current - 1); - }; const nextPeriod = () => { - if (!isMobile) { - nextYear(); - return; - } if (selectedMonth === 11) { - setSelectedMonth(0); - setYear((current) => current + 1); - return; - } setSelectedMonth((current) => current + 1); - }; const currentPeriod = () => { - const now = new Date(); - - setYear(now.getFullYear()); + setYear( + !isMobile && schoolView && now.getMonth() < 8 + ? now.getFullYear() - 1 + : now.getFullYear(), + ); if (isMobile) { - setSelectedMonth(now.getMonth()); - } - }; - /* - * ============================================================ - * SURVOL - * ============================================================ - */ - const handleEventMouseEnter = ( - event, - mouseEvent, - ) => { - setHoveredEvent(event); - setHoverPosition({ - x: mouseEvent.clientX, - y: mouseEvent.clientY, - }); - }; - const handleEventMouseMove = ( - mouseEvent, - ) => { - setHoverPosition({ - x: mouseEvent.clientX, - y: mouseEvent.clientY, - }); - }; - const handleEventMouseLeave = () => { - setHoveredEvent(null); - setHoverPosition(null); - }; - /* - * ============================================================ - * MENU CONTEXTUEL - * ============================================================ - */ - const closeContextMenu = () => { - setContextMenu(null); - }; - const handleDayContextMenu = ( - mouseEvent, - date, - ) => { - mouseEvent.preventDefault(); - mouseEvent.stopPropagation(); - setHoveredEvent(null); - setHoverPosition(null); - - const selection = isMobile ? null : getOrderedSelection(); - + const selection = getOrderedSelection(); const useSelection = Boolean( - selection && - date >= selection.start && - date <= selection.end, - ); - setContextMenu({ - x: mouseEvent.clientX, - y: mouseEvent.clientY, - type: useSelection ? 'selection' : 'day', - date, - startDate: useSelection ? selection.start : date, - endDate: useSelection ? selection.end : date, - }); - }; - const handleEventContextMenu = ( - mouseEvent, - event, - ) => { - mouseEvent.preventDefault(); - mouseEvent.stopPropagation(); - setHoveredEvent(null); - setHoverPosition(null); - setContextMenu({ - x: mouseEvent.clientX, - y: mouseEvent.clientY, - type: 'event', - event, - }); - }; - useEffect(() => { - if (!contextMenu) return; - const handleOutsideClick = () => { - closeContextMenu(); - }; - const handleEscape = ( - keyboardEvent, - ) => { - if ( - keyboardEvent.key === 'Escape' - ) { - closeContextMenu(); - } - }; - document.addEventListener( - 'mousedown', - handleOutsideClick, - ); - document.addEventListener( - 'keydown', - handleEscape, - ); - return () => { - document.removeEventListener( - 'mousedown', - handleOutsideClick, - ); - document.removeEventListener( - 'keydown', - handleEscape, - ); - }; - }, [contextMenu]); - /* - * ============================================================ - * FORMULAIRE ÉVÉNEMENT - * ============================================================ - */ - const resetEventForm = () => { - setEventForm({ - name: '', - description: '', - start_date: '', - end_date: '', - start_time: '', - end_time: '', - color: DEFAULT_EVENT_COLOR, - }); - }; - const openCreateDialog = ( - startDate = '', - endDate = startDate, - ) => { - setEditingEvent(null); - setEventForm({ - name: '', - description: '', - start_date: startDate, - end_date: endDate || startDate, - start_time: '', - end_time: '', - color: DEFAULT_EVENT_COLOR, - }); - clearSelection(); - closeContextMenu(); - setEventDialogOpen(true); - }; - const openEditDialog = ( - event, - ) => { - setEditingEvent(event); - setEventForm({ - name: event?.name ?? '', - description: - event?.description ?? '', - start_date: - event?.start_date ?? '', - end_date: - event?.end_date ?? - event?.start_date ?? - '', - start_time: - event?.start_time - ? String( - event.start_time, - ).slice(0, 5) - : '', - end_time: - event?.end_time - ? String( - event.end_time, - ).slice(0, 5) - : '', - color: - event?.color || - DEFAULT_EVENT_COLOR, - }); - closeContextMenu(); - setEventDialogOpen(true); - }; - const handleFormChange = ( - field, - value, - ) => { - setEventForm( - (current) => ({ - ...current, - [field]: value, - }), - ); - }; - const handleStartDateChange = ( - value, - ) => { - setEventForm( - (current) => { - const next = { - ...current, - start_date: value, - }; - /* - * Si la date de fin est vide ou - * antérieure à la nouvelle date - * de début, on la recale sur - * la date de début. - */ - if ( - !current.end_date || - current.end_date < value - ) { - next.end_date = value; - } - return next; - }, - ); - }; - const handleSaveEvent = async ( - submitEvent, - ) => { - setRequestError(''); - if (!eventForm.name.trim()) { - return; - } - if (!eventForm.start_date) { - return; - } - const startDate = - parseDate( - eventForm.start_date, - ); - const endDate = - parseDate( - eventForm.end_date || - eventForm.start_date, - ); - if ( - !startDate || - !endDate || - endDate < startDate - ) { - return; - } - try { - setSavingEvent(true); - const payload = { - name: eventForm.name.trim(), - description: - eventForm.description.trim(), - start_date: - eventForm.start_date, - end_date: - eventForm.end_date || - eventForm.start_date, - start_time: - eventForm.start_time || null, - end_time: - eventForm.end_time || null, - color: - eventForm.color || - DEFAULT_EVENT_COLOR, - }; - if (submitEvent) { - await eventApi.updateEvent( - editingEvent.id, - payload, - ); - } else { - await eventApi.createEvent( - payload, - ); - } - setEventDialogOpen(false); - setEditingEvent(null); - resetEventForm(); - await loadEvents(); - } catch (error) { - setRequestError(error.message); - console.error( - submitEvent - ? 'Erreur lors de la modification de l’événement :' - : 'Erreur lors de la création de l’événement :', - error, - ); - } finally { - setSavingEvent(false); - } - }; - const handleDeleteEvent = async ( - event, - ) => { - closeContextMenu(); - const confirmed = - window.confirm( - `Supprimer l’événement « ${ - event?.name || 'Sans nom' - } » ?`, - ); - if (!confirmed) { - return; - } - try { - await eventApi.deleteEvent( - event.id, - ); - await loadEvents(); - } catch (error) { - setRequestError(error.message); - console.error( - 'Erreur lors de la suppression de l’événement :', - error, - ); - } - }; - /* - * ============================================================ - * AUJOURD'HUI - * ============================================================ - */ - const today = new Date(); - const isToday = ( - monthIndex, - day, - ) => { - return ( - - today.getFullYear() === year && - + today.getFullYear() === getMonthYear(monthIndex) && today.getMonth() === monthIndex && - today.getDate() === day - ); - }; - /* - * ============================================================ - * ÉVÉNEMENTS DE L'ANNÉE - * ============================================================ - */ - const yearEvents = events.filter( - (event) => { - const start = parseDate( - getEventStartDate(event), - ); - const end = parseDate( - getEventEndDate(event), - ); - if (!start || !end) { - return false; - } - - const yearStart = new Date( - - year, - - 0, - - 1, - - ); - - const yearEnd = new Date( - - year, - - 11, - - 31, - - ); - + const yearStart = new Date(year, !isMobile && schoolView ? 8 : 0, 1); + const yearEnd = !isMobile && schoolView + ? new Date(year + 1, 7, 31) + : new Date(year, 11, 31); return ( - end >= yearStart && - start <= yearEnd - ); - }, - ); - /* - * ============================================================ - * LANES DES ÉVÉNEMENTS CONTINUS - * ============================================================ - */ - const eventLanes = useMemo(() => { - const periodEvents = yearEvents - .filter((event) => !isSingleDayEvent(event)) - .map((event) => ({ - event, - start: parseDate(getEventStartDate(event)), - end: parseDate(getEventEndDate(event)), - })) - .filter(({ start, end }) => start && end) - .sort((a, b) => { - const startDifference = a.start - b.start; - if (startDifference !== 0) return startDifference; - const endDifference = b.end - a.end; - if (endDifference !== 0) return endDifference; - return String(a.event.id).localeCompare(String(b.event.id)); - }); - const laneEndDates = []; - const lanes = new Map(); - periodEvents.forEach(({ event, start, end }) => { - let lane = laneEndDates.findIndex( - (laneEndDate) => laneEndDate < start, - ); - if (lane === -1) { - lane = laneEndDates.length; - } - laneEndDates[lane] = end; - lanes.set(String(event.id), lane); - }); - return lanes; - }, [yearEvents]); - /* - * ============================================================ - * DIMENSIONS - * ============================================================ - */ - const displayedMonthIndices = isMobile - ? [selectedMonth] - - : MONTHS.map((_, index) => index); + : schoolView + ? [8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7] + : MONTHS.map((_, index) => index); const rowCount = isMobile - ? getDaysInMonth(selectedMonth) - : 31; const availableGridHeight = Math.max( - availableHeight - HEADER_HEIGHT, - rowCount * MIN_ROW_HEIGHT, - ); const rowHeight = Math.max( - MIN_ROW_HEIGHT, - availableGridHeight / rowCount, - ); const minimumGridWidth = isMobile - ? 0 - : DAY_COLUMN_WIDTH + - MONTHS.length * MIN_MONTH_WIDTH; const gridTemplateColumns = isMobile - ? `${DAY_COLUMN_WIDTH}px minmax(0, 1fr)` - : ` - ${DAY_COLUMN_WIDTH}px - repeat( - 12, - minmax( - ${MIN_MONTH_WIDTH}px, - 1fr - ) - ) - `; - /* - * ============================================================ - * RENDER - * ============================================================ - */ - if (loadingEvents) { - return ( - - ); - } - return ( - <> - {/* ====================================================== - HOVER EVENT - ====================================================== */} - {hoveredEvent && ( - - )} - {/* ====================================================== - MENU CONTEXTUEL - ====================================================== */} - {contextMenu && ( -
{ - mouseEvent.stopPropagation(); - }} - onContextMenu={( - mouseEvent, - ) => { - mouseEvent.preventDefault(); - mouseEvent.stopPropagation(); - }} - > - {(contextMenu.type === 'day' || - contextMenu.type === 'selection') && ( - - )} - {contextMenu.type === - 'event' && ( - <> - -
- - - )} -
- )} - {/* ====================================================== - DIALOG CRÉATION / MODIFICATION - ====================================================== */} - { - if ( - savingEvent && - !open - ) { - return; - } - setEventDialogOpen(open); - if (!open) { - setEditingEvent(null); - resetEventForm(); - } - }} - > - - - - {editingEvent - ? 'Modifier l’événement' - : 'Créer un événement'} - - - {editingEvent - ? 'Modifie les informations de cet événement.' - : 'Ajoute un nouvel événement au calendrier.'} - - - {requestError && ( - {requestError} - )} -
- {/* NOM */} -
- - - handleFormChange( - 'name', - event.target.value, - ) - } - placeholder="Nom de l'événement" - autoFocus - /> -
- {/* DESCRIPTION */} -
- -