2003 lines
63 KiB
JavaScript
2003 lines
63 KiB
JavaScript
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;
|