import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
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';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { can } from '@/lib/permissions';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import {
ChevronLeft,
ChevronRight,
Calendar,
Plus,
Pencil,
Trash2,
ArrowLeftRight,
} from 'lucide-react';
import { eventApi } from '../api/event';
import { useAuth } from '../context/useAuth';
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,
];
const WEEKDAY_INITIALS = ['D', 'L', 'M', 'M', 'J', 'V', 'S'];
const HEADER_HEIGHT = 35;
const DAY_COLUMN_WIDTH = 45;
const MIN_MONTH_WIDTH = 90;
const MIN_ROW_HEIGHT = 17.5;
const DEFAULT_EVENT_COLOR = '#2563eb';
const EVENT_BAR_WIDTH = 8;
const EVENT_LANE_GAP = 11;
const EVENT_LEFT_PADDING = 3;
const CalendarPage = () => {
const { user, isAdmin } = useAuth();
const canCreate = can(user, 'create_personal_event');
const canManageEvent = (event) => {
const creatorId = event?.created_by?.id ?? event?.created_by;
return user?.id != null && creatorId != null &&
String(creatorId) === String(user.id);
};
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 [calendarViewportHeight, setCalendarViewportHeight] =
useState(null);
const [events, setEvents] =
useState([]);
const [loadingEvents, setLoadingEvents] =
useState(true);
const [eventProfiles, setEventProfiles] = useState([]);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState('manual');
const [savingProfile, setSavingProfile] = useState(false);
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: '',
start_date: '',
end_date: '',
start_time: '',
end_time: '',
color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
});
const calendarViewportRef = 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(getMonthYear(monthIndex))
) {
return 29;
}
return DAYS_IN_MONTH[monthIndex];
};
const formatDateForInput = (
monthIndex,
day,
) => {
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(
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(
getMonthYear(monthIndex),
monthIndex,
day,
);
return {
isStart:
currentDate.getTime() ===
startDate.getTime(),
isEnd:
currentDate.getTime() ===
endDate.getTime(),
};
};
const getOrderedSelection = () => {
if (isMobile) return null;
if (!selectionStart || !selectionEnd) return null;
return selectionStart <= selectionEnd
? { start: selectionStart, end: selectionEnd }
: { start: selectionEnd, end: selectionStart };
};
const isDateSelected = (date) => {
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 (!canCreate || isMobile || pointerEvent.pointerType === 'touch' || 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 (isMobile || !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);
}
};
const loadEventProfiles = async () => {
if (!canCreate) {
setEventProfiles([]);
return;
}
try {
setLoadingProfiles(true);
const data = await eventApi.getProfiles();
setEventProfiles(Array.isArray(data) ? data : data?.results ?? []);
} catch (error) {
console.error('Erreur lors du chargement des profils d’événement :', error);
setEventProfiles([]);
} finally {
setLoadingProfiles(false);
}
};
useEffect(() => {
loadEvents();
loadEventProfiles();
}, []);
/*
* ============================================================
* 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);
};
}, []);
useEffect(() => {
if (isMobile) clearSelection();
}, [isMobile]);
useLayoutEffect(() => {
if (loadingEvents || !calendarViewportRef.current) return;
const viewport = calendarViewportRef.current;
const updateHeight = () => {
const height = viewport.getBoundingClientRect().height;
setCalendarViewportHeight((previous) =>
previous === height ? previous : height,
);
};
updateHeight();
const observer = new ResizeObserver(updateHeight);
observer.observe(viewport);
return () => observer.disconnect();
}, [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(
!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();
if (!canCreate) return;
setHoveredEvent(null);
setHoverPosition(null);
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: '',
start_date: '',
end_date: '',
start_time: '',
end_time: '',
color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
});
setSelectedProfileId('manual');
};
const openCreateDialog = (
startDate = '',
endDate = startDate,
) => {
if (!canCreate) return;
setRequestError('');
setEditingEvent(null);
setSelectedProfileId('manual');
setEventForm({
name: '',
start_date: startDate,
end_date: endDate || startDate,
start_time: '',
end_time: '',
color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
});
clearSelection();
closeContextMenu();
setEventDialogOpen(true);
};
const openEditDialog = (
event,
) => {
if (!canManageEvent(event)) return;
setRequestError('');
setEditingEvent(event);
setSelectedProfileId('manual');
setEventForm({
name: event?.name ?? '',
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,
is_public: Boolean(isAdmin) && event?.is_public === true,
});
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 formatProfileTime = (value) => {
if (!value) return '';
return String(value).slice(0, 5);
};
const handleProfileChange = (value) => {
setSelectedProfileId(value);
};
const handleLoadProfile = () => {
if (selectedProfileId === 'manual') return;
const profile = eventProfiles.find(
(item) => String(item.id) === String(selectedProfileId),
);
if (!profile) return;
setEventForm((current) => ({
...current,
name: profile.name ?? '',
start_time: formatProfileTime(profile.start_time),
end_time: formatProfileTime(profile.end_time),
color: profile.color || DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && profile.is_public === true,
}));
};
const getProfilePayload = () => ({
name: eventForm.name.trim(),
start_time: eventForm.start_time || null,
end_time: eventForm.end_time || null,
color: eventForm.color || DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && eventForm.is_public,
});
const handleSaveProfile = async () => {
if (savingProfile || !eventForm.name.trim()) return;
try {
setRequestError('');
setSavingProfile(true);
const payload = getProfilePayload();
let savedProfile;
if (selectedProfileId !== 'manual') {
savedProfile = await eventApi.updateProfile(selectedProfileId, payload);
} else {
savedProfile = await eventApi.createProfile(payload);
}
await loadEventProfiles();
setSelectedProfileId(String(savedProfile.id));
} catch (error) {
setRequestError(error.message);
console.error('Erreur lors de l’enregistrement du profil d’événement :', error);
} finally {
setSavingProfile(false);
}
};
const handleDeleteProfile = async () => {
if (savingProfile || selectedProfileId === 'manual') return;
const profile = eventProfiles.find((item) => String(item.id) === String(selectedProfileId));
if (!profile) return;
const confirmed = window.confirm(`Supprimer le profil « ${profile.name} » ?`);
if (!confirmed) return;
try {
setRequestError('');
setSavingProfile(true);
await eventApi.deleteProfile(profile.id);
setSelectedProfileId('manual');
await loadEventProfiles();
} catch (error) {
setRequestError(error.message);
console.error('Erreur lors de la suppression du profil d’événement :', error);
} finally {
setSavingProfile(false);
}
};
const handleSaveEvent = async (
submitEvent,
) => {
setRequestError('');
if (!editingEvent && !canCreate) { setRequestError('L’ajout d’événements n’est pas activé pour ton compte.'); return; }
if (savingEvent || (editingEvent && !canManageEvent(editingEvent))) return;
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(),
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,
is_public: Boolean(isAdmin) && eventForm.is_public,
};
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();
if (!canManageEvent(event)) return;
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() === 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, !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]
: schoolView
? [8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7]
: MONTHS.map((_, index) => index);
const rowCount = isMobile
? getDaysInMonth(selectedMonth)
: 31;
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 (
Événement public · Lecture seule
)} {contextMenu.type === 'event' && canManageEvent(contextMenu.event) && ( <> > )}{canCreate ? 'Tes événements personnels sont visibles uniquement par toi.' : 'Pour ajouter un événement personnel, demande l’accès à un responsable.'}
)} {canCreate && }