Louis Mazin 4db822aebe big maj
2026-10-05 17:52:28 +02:00

2003 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<LoadingCard
icon={Calendar}
title={
isMobile ? 'Calendrier' : 'Calendrier annuel'
}
message="Chargement du calendrier..."
/>
);
}
return (
<>
{/* ======================================================
HOVER EVENT
====================================================== */}
{hoveredEvent && (
<HoverInfo
title={hoveredEvent.name}
startTime={
hoveredEvent.start_time
}
endTime={
hoveredEvent.end_time
}
color={
hoveredEvent.color
}
position={
hoverPosition
}
/>
)}
{/* ======================================================
MENU CONTEXTUEL
====================================================== */}
{contextMenu && (
<div
className="
fixed
z-[10000]
min-w-[190px]
overflow-hidden
rounded-md
border
border-border
bg-popover
p-1
text-popover-foreground
shadow-md
"
style={{
left: `${contextMenu.x}px`,
top: `${contextMenu.y}px`,
}}
onMouseDown={(
mouseEvent,
) => {
mouseEvent.stopPropagation();
}}
onContextMenu={(
mouseEvent,
) => {
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
}}
>
{canCreate && (contextMenu.type === 'day' ||
contextMenu.type === 'selection') && (
<button
type="button"
className="
flex
w-full
items-center
gap-2
rounded-sm
px-2
py-1.5
text-sm
outline-none
transition-colors
hover:bg-accent
hover:text-accent-foreground
"
onClick={() =>
openCreateDialog(
contextMenu.startDate ?? contextMenu.date,
contextMenu.endDate ?? contextMenu.date,
)
}
>
<Plus className="h-4 w-4" />
{contextMenu.type === 'selection'
? 'Créer un événement sur la période'
: 'Créer un événement'}
</button>
)}
{contextMenu.type === 'event' && !canManageEvent(contextMenu.event) && (
<p className="px-2 py-1.5 text-sm text-muted-foreground">
Événement public · Lecture seule
</p>
)}
{contextMenu.type ===
'event' && canManageEvent(contextMenu.event) && (
<>
<button
type="button"
className="
flex
w-full
items-center
gap-2
rounded-sm
px-2
py-1.5
text-sm
outline-none
transition-colors
hover:bg-accent
hover:text-accent-foreground
"
onClick={() =>
openEditDialog(
contextMenu.event,
)
}
>
<Pencil className="h-4 w-4" />
Modifier
</button>
<div className="my-1 h-px bg-border" />
<button
type="button"
className="
flex
w-full
items-center
gap-2
rounded-sm
px-2
py-1.5
text-sm
text-destructive
outline-none
transition-colors
hover:bg-destructive/10
"
onClick={() =>
handleDeleteEvent(
contextMenu.event,
)
}
>
<Trash2 className="h-4 w-4" />
Supprimer
</button>
</>
)}
</div>
)}
{/* ======================================================
DIALOG CRÉATION / MODIFICATION
====================================================== */}
<Dialog
open={eventDialogOpen}
onOpenChange={(
open,
) => {
if (
savingEvent &&
!open
) {
return;
}
setEventDialogOpen(open);
if (!open) {
setEditingEvent(null);
resetEventForm();
}
}}
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[550px]">
<DialogHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div className="min-w-0 space-y-1.5">
<DialogTitle>
{editingEvent
? 'Modifier l’événement'
: 'Créer un événement'}
</DialogTitle>
<DialogDescription>
{editingEvent
? 'Modifie les informations de cet événement.'
: 'Ajoute un nouvel événement au calendrier.'}
</DialogDescription>
</div>
{!editingEvent && canCreate && (
<div className="flex w-full min-w-0 items-center gap-2 sm:w-auto sm:max-w-[310px]">
<Select
value={selectedProfileId}
onValueChange={handleProfileChange}
disabled={loadingProfiles || savingProfile}
>
<SelectTrigger
id="event-profile"
aria-label="Profil d’événement"
className="min-w-0 flex-1 sm:w-[210px]"
>
<span
className={`min-w-0 flex-1 truncate text-left ${
selectedProfileId === 'manual'
? 'text-muted-foreground'
: ''
}`}
>
{selectedProfileId === 'manual'
? 'Sélectionner'
: eventProfiles.find(
(profile) => String(profile.id) === selectedProfileId
)?.name || 'Sélectionner'}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Aucun profil</SelectItem>
{eventProfiles.map((profile) => (
<SelectItem key={profile.id} value={String(profile.id)}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={
loadingProfiles ||
savingProfile ||
selectedProfileId === 'manual'
}
onClick={handleLoadProfile}
>
Charger
</Button>
</div>
)}
</DialogHeader>
{requestError && (
<Alert variant="destructive"><AlertDescription>{requestError}</AlertDescription></Alert>
)}
<div className="grid gap-4 py-2">
{/* NOM */}
<div className="grid gap-2">
<Label htmlFor="event-name">
Nom
</Label>
<Input
id="event-name"
value={
eventForm.name
}
onChange={(
event,
) =>
handleFormChange(
'name',
event.target.value,
)
}
placeholder="Nom de l'événement"
autoFocus
/>
</div>
{/* DATES */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="event-start-date">
Date de début
</Label>
<DatePicker
id="event-start-date"
value={
eventForm.start_date
}
onChange={(value) =>
handleStartDateChange(
value,
)
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="event-end-date">
Date de fin
</Label>
<DatePicker
id="event-end-date"
min={
eventForm.start_date ||
undefined
}
value={
eventForm.end_date
}
onChange={(value) =>
handleFormChange(
'end_date',
value,
)
}
/>
</div>
</div>
{/* HEURES */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="event-start-time">
Heure de début
<span className="ml-1 text-muted-foreground">
(optionnelle)
</span>
</Label>
<TimePicker
id="event-start-time"
value={
eventForm.start_time
}
onChange={(value) =>
handleFormChange(
'start_time',
value,
)
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="event-end-time">
Heure de fin
<span className="ml-1 text-muted-foreground">
(optionnelle)
</span>
</Label>
<TimePicker
id="event-end-time"
value={
eventForm.end_time
}
onChange={(value) =>
handleFormChange(
'end_time',
value,
)
}
/>
</div>
</div>
{/* COULEUR ET VISIBILITÉ */}
<div className="flex items-start gap-4">
<div className="grid min-w-0 flex-1 gap-2">
<Label htmlFor="event-color">Couleur</Label>
<div className="flex min-w-0 items-center gap-2">
<input
id="event-color"
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(eventForm.color)
? eventForm.color : DEFAULT_EVENT_COLOR}
onChange={(event) => handleFormChange('color', event.target.value)}
className="h-10 w-14 shrink-0 cursor-pointer rounded-md border border-input bg-background p-1"
/>
<Input
aria-label="Code hexadécimal de la couleur"
value={eventForm.color}
onChange={(event) => handleFormChange('color', event.target.value)}
placeholder="#2563eb"
className="min-w-0 flex-1"
/>
</div>
</div>
{isAdmin && (
<div className="grid shrink-0 gap-2">
<Label htmlFor="event-is-public">Visibilité</Label>
<div className="flex h-10 items-center gap-2">
<Switch id="event-is-public" aria-label="Événement public" aria-describedby="event-visibility-help" checked={eventForm.is_public} disabled={savingEvent} onCheckedChange={(checked) => setEventForm((current) => ({ ...current, is_public: checked }))} />
<Label htmlFor="event-is-public" className="w-10 cursor-pointer">
{eventForm.is_public ? 'Public' : 'Privé'}
</Label>
</div>
</div>
)}
</div>
<p id="event-visibility-help" className="text-xs text-muted-foreground">
{isAdmin && eventForm.is_public
? 'Visible par tous les utilisateurs connectés. Seul toi peux le modifier ou le supprimer.'
: 'Visible uniquement par toi dans ton calendrier.'}
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{!editingEvent && canCreate && (
<div className="flex items-center gap-2 sm:mr-auto">
<Button
type="button"
variant="secondary"
disabled={savingProfile || !eventForm.name.trim()}
onClick={handleSaveProfile}
>
{savingProfile ? 'Enregistrement...' : 'Sauvegarder le profil'}
</Button>
{selectedProfileId !== 'manual' && (
<Button
type="button"
size="icon"
variant="outline"
disabled={savingProfile}
onClick={handleDeleteProfile}
aria-label="Supprimer le profil sélectionné"
title="Supprimer le profil sélectionné"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
)}
<Button
type="button"
variant="outline"
disabled={
savingEvent
}
onClick={() =>
setEventDialogOpen(
false,
)
}
>
Annuler
</Button>
<Button
type="button"
disabled={
savingEvent ||
!eventForm.name.trim() ||
!eventForm.start_date
}
onClick={() =>
handleSaveEvent(
Boolean(
editingEvent,
),
)
}
>
{savingEvent
? 'Enregistrement...'
: editingEvent
? 'Enregistrer'
: 'Créer'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ======================================================
CARTE PRINCIPALE
====================================================== */}
<MainCard
title="Calendrier annuel"
icon={Calendar}
headerContent={
<>
<Button
variant="outline"
size="sm"
onClick={previousPeriod}
>
<ChevronLeft className="h-4 w-4" />
{!isMobile &&
'Précédente'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={currentPeriod}
>
{isMobile
? `${MONTHS[selectedMonth]} ${year}`
: schoolView
? `${year}–${year + 1}`
: year}
</Button>
<Button
variant="outline"
size="sm"
onClick={nextPeriod}
>
{!isMobile &&
'Suivante'}
<ChevronRight className="h-4 w-4" />
</Button>
{!isMobile && (
<Button
variant={schoolView ? 'secondary' : 'outline'}
size="sm"
onClick={() => {
clearSelection();
setSchoolView((current) => !current);
}}
title={schoolView ? 'Passer en vue annuelle' : 'Passer en vue scolaire'}
>
<ArrowLeftRight className="mr-2 h-4 w-4" />
{schoolView ? 'Vue scolaire' : 'Vue annuelle'}
</Button>
)}
</>
}
>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
{!isAdmin && (
<p className="text-xs text-muted-foreground">
{canCreate ? 'Tes événements personnels sont visibles uniquement par toi.' : 'Pour ajouter un événement personnel, demande l’accès à un responsable.'}
</p>
)}
{canCreate && <Button className="ml-auto" size="sm" onClick={() => { const now = new Date(); const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; openCreateDialog(day); }}><Plus className="size-4" />Ajouter un événement</Button>}
</div>
{requestError && (
<Alert variant="destructive" className="mb-3">
<AlertDescription>{requestError}</AlertDescription>
</Alert>
)}
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
<div
ref={calendarViewportRef}
className="relative min-h-0 flex-1 overflow-auto md:overflow-hidden p-0"
style={{
WebkitOverflowScrolling:
'touch',
touchAction:
'pan-x pan-y',
}}
>
<div
className="grid w-full min-w-0"
style={{
height: calendarViewportHeight === null
? '100%'
: `${Math.max(calendarViewportHeight, HEADER_HEIGHT + rowCount * MIN_ROW_HEIGHT)}px`,
minHeight: `${HEADER_HEIGHT + rowCount * MIN_ROW_HEIGHT}px`,
minWidth: isMobile
? '100%'
: `${minimumGridWidth}px`,
gridTemplateColumns,
gridTemplateRows: `${HEADER_HEIGHT}px repeat(${rowCount}, minmax(${MIN_ROW_HEIGHT}px, 1fr))`,
boxSizing:
'border-box',
}}
>
{/* ==================================================
HEADER JOUR
================================================== */}
<div
className="
sticky left-0 top-0 z-[3]
flex items-center justify-center
border-b border-r border-border
bg-background
px-2
text-xs font-medium
text-muted-foreground
"
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
Jour
</div>
{/* ==================================================
HEADER MOIS
================================================== */}
{displayedMonthIndices.map(
(monthIndex) => (
<div
key={MONTHS[monthIndex]}
className={`
sticky top-0 z-[2]
flex items-center justify-center
border-b border-border
${monthIndex !== displayedMonthIndices[displayedMonthIndices.length - 1] ? 'border-r' : ''}
bg-background
px-3
text-center
text-sm
font-medium
`}
style={{
height: `${HEADER_HEIGHT}px`,
}}
>
{MONTHS[monthIndex]}
</div>
),
)}
{/* ==================================================
JOURS
================================================== */}
{Array.from(
{ length: rowCount },
(_, dayIndex) => {
const day =
dayIndex + 1;
return (
<React.Fragment
key={day}
>
{/* NUMÉRO DU JOUR */}
<div
className={`
sticky left-0 z-[1]
flex items-center justify-center
border-r border-border
${dayIndex < rowCount - 1 ? 'border-b' : ''}
bg-card
px-2
text-xs font-medium
text-muted-foreground
`}
>
{day}
</div>
{/* ==================================================
MOIS
================================================== */}
{displayedMonthIndices.map(
(monthIndex) => {
const valid =
day <=
getDaysInMonth(
monthIndex,
);
const todayCell =
valid &&
isToday(
monthIndex,
day,
);
if (!valid) {
return (
<div
key={`${monthIndex}-${day}`}
className={`
border-border
${dayIndex < rowCount - 1 ? 'border-b' : ''}
${monthIndex !== displayedMonthIndices[displayedMonthIndices.length - 1] ? 'border-r' : ''}
bg-transparent
`}
/>
);
}
const cellDate =
formatDateForInput(
monthIndex,
day,
);
const cellDayNumber =
Math.floor(
Date.UTC(getMonthYear(monthIndex), monthIndex, day) / 86400000,
);
const alternateWeek =
Math.floor((cellDayNumber + 3) / 7) % 2 !== 0;
const weekdayInitial =
WEEKDAY_INITIALS[
new Date(Date.UTC(getMonthYear(monthIndex), monthIndex, day)).getUTCDay()
];
const selectedCell =
isDateSelected(cellDate);
const dayEvents =
yearEvents.filter(
(
event,
) =>
isDateInEvent(
event,
monthIndex,
day,
),
);
const periodEvents =
dayEvents.filter(
(
event,
) =>
!isSingleDayEvent(
event,
),
);
const singleDayEvents =
dayEvents.filter(
(
event,
) =>
isSingleDayEvent(
event,
),
);
return (
<div
key={`${monthIndex}-${day}`}
className={`
relative isolate
border-border
${dayIndex < rowCount - 1 ? 'border-b' : ''}
${monthIndex !== displayedMonthIndices[displayedMonthIndices.length - 1] ? 'border-r' : ''}
${todayCell ? 'bg-muted' : alternateWeek ? 'bg-muted/40' : 'bg-card'}
px-2
transition-colors
hover:bg-muted/40
${
selectedCell
? 'bg-primary/15 ring-1 ring-inset ring-primary/40'
: ''
}
${isSelecting ? 'select-none' : ''}
`}
data-calendar-date={cellDate}
style={{
overflow:
'visible',
}}
onPointerDown={(pointerEvent) =>
beginSelection(
pointerEvent,
cellDate,
)
}
onContextMenu={(
mouseEvent,
) =>
handleDayContextMenu(
mouseEvent,
cellDate,
)
}
>
{/* AUJOURD'HUI */}
{todayCell && (
<div
className="
absolute
left-1/2
top-1/2
flex
h-[min(14px,calc(100%))]
min-w-[min(14px,calc(100%))]
-translate-x-1/2
-translate-y-1/2
items-center
justify-center
rounded-full
bg-primary
px-1
text-[11px]
font-semibold
text-primary-foreground
"
>
{weekdayInitial}
</div>
)}
{!todayCell && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[10px] font-normal text-muted-foreground/30"
>
{weekdayInitial}
</span>
)}
{/* ==================================================
ÉVÉNEMENTS SUR UNE PÉRIODE
================================================== */}
{periodEvents.map(
(
event,
) => {
const position =
getPeriodEventPosition(
event,
monthIndex,
day,
);
if (
!position
) {
return null;
}
const lane =
eventLanes.get(
String(event.id),
) ?? 0;
const laneOffset =
lane *
EVENT_LANE_GAP;
const isStart =
position.isStart;
const isEnd =
position.isEnd;
const eventColor =
event.color ||
DEFAULT_EVENT_COLOR;
return (
<div
key={`period-${event.id}`}
className="
absolute
z-20
cursor-pointer
"
style={{
left: `${EVENT_LEFT_PADDING + laneOffset}px`,
width: `${EVENT_BAR_WIDTH}px`,
top: isStart ? '2px' : '-1px',
bottom: isEnd ? '2px' : '-1px',
borderRadius: `${isStart ? '9999px' : '0'} ${isStart ? '9999px' : '0'} ${isEnd ? '9999px' : '0'} ${isEnd ? '9999px' : '0'}`,
backgroundColor:
eventColor,
}}
onPointerDown={(pointerEvent) => {
pointerEvent.stopPropagation();
}}
onMouseEnter={(
mouseEvent,
) =>
handleEventMouseEnter(
event,
mouseEvent,
)
}
onMouseMove={
handleEventMouseMove
}
onMouseLeave={
handleEventMouseLeave
}
onContextMenu={(
mouseEvent,
) =>
handleEventContextMenu(
mouseEvent,
event,
)
}
/>
);
},
)}
{/* ==================================================
ÉVÉNEMENTS SUR UNE SEULE DATE
================================================== */}
{singleDayEvents.length >
0 && (
<div
className="
pointer-events-none
absolute
right-1
top-1/2
z-20
flex
-translate-y-1/2
flex-col
gap-1
"
>
{singleDayEvents.map(
(
event,
) => {
const eventColor =
event.color ||
DEFAULT_EVENT_COLOR;
return (
<div
key={`single-${event.id}`}
className="
pointer-events-auto
cursor-pointer
rounded-full
"
style={{
width: '12px',
height: '12px',
backgroundColor: eventColor,
}}
onPointerDown={(pointerEvent) => {
pointerEvent.stopPropagation();
}}
onMouseEnter={(
mouseEvent,
) =>
handleEventMouseEnter(
event,
mouseEvent,
)
}
onMouseMove={
handleEventMouseMove
}
onMouseLeave={
handleEventMouseLeave
}
onContextMenu={(
mouseEvent,
) =>
handleEventContextMenu(
mouseEvent,
event,
)
}
/>
);
},
)}
</div>
)}
</div>
);
},
)}
</React.Fragment>
);
},
)}
</div>
</div>
</Card>
</CardContent>
</MainCard>
</>
);
};
export default CalendarPage;