From 884062658a35e35e2fcea9dfcf5836ef08400441 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Sun, 25 Jan 2026 20:34:25 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20Scheduler=20modals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add EventModal for creating and editing events with support for recurrence, attendees and all-day events. Add DeleteEventModal with options for recurring event deletion scope. Co-Authored-By: Claude Opus 4.5 --- .../components/scheduler/DeleteEventModal.tsx | 95 +++ .../components/scheduler/EventModal.tsx | 620 ++++++++++++++++++ 2 files changed, 715 insertions(+) create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/DeleteEventModal.tsx create mode 100644 src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/DeleteEventModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/DeleteEventModal.tsx new file mode 100644 index 0000000..f08a966 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/DeleteEventModal.tsx @@ -0,0 +1,95 @@ +/** + * DeleteEventModal component. + * Displays options for deleting recurring events or confirms single event deletion. + */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react"; + +import type { DeleteEventModalProps, RecurringDeleteOption } from "./types"; + +export const DeleteEventModal = ({ + isOpen, + isRecurring, + onConfirm, + onCancel, +}: DeleteEventModalProps) => { + const { t } = useTranslation(); + const [selectedOption, setSelectedOption] = + useState('this'); + + return ( + + + + + } + > +
+ {isRecurring ? ( + <> +

+ {t('calendar.event.deleteRecurringPrompt')} +

+
+ + + +
+ + ) : ( +

+ {t('calendar.event.deleteConfirmMessage')} +

+ )} +
+
+ ); +}; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx new file mode 100644 index 0000000..af11f3d --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/EventModal.tsx @@ -0,0 +1,620 @@ +/** + * EventModal component. + * Handles creation and editing of calendar events. + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + IcsEvent, + IcsAttendee, + IcsOrganizer, + IcsRecurrenceRule, +} from "ts-ics"; +import { + Button, + Input, + Modal, + ModalSize, + Select, + TextArea, +} from "@gouvfr-lasuite/cunningham-react"; + +import { useAuth } from "@/features/auth/Auth"; +import { AttendeesInput } from "../AttendeesInput"; +import { RecurrenceEditor } from "../RecurrenceEditor"; +import { DeleteEventModal } from "./DeleteEventModal"; +import type { EventModalProps, RecurringDeleteOption } from "./types"; +import { + formatDateTimeLocal, + formatDateLocal, + parseDateTimeLocal, + parseDateLocal, +} from "./utils/dateFormatters"; + +// Get browser timezone +const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; + +export const EventModal = ({ + isOpen, + mode, + event, + calendarUrl, + calendars, + adapter, + onSave, + onDelete, + onRespondToInvitation, + onClose, +}: EventModalProps) => { + const { t } = useTranslation(); + const { user } = useAuth(); + const [title, setTitle] = useState(event?.summary || ""); + const [description, setDescription] = useState(event?.description || ""); + const [location, setLocation] = useState(event?.location || ""); + const [startDateTime, setStartDateTime] = useState(""); + const [endDateTime, setEndDateTime] = useState(""); + const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl); + const [isLoading, setIsLoading] = useState(false); + const [attendees, setAttendees] = useState([]); + const [showAttendees, setShowAttendees] = useState(false); + const [recurrence, setRecurrence] = useState( + event?.recurrenceRule + ); + const [showRecurrence, setShowRecurrence] = useState(() => { + return !!event?.recurrenceRule; + }); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [isAllDay, setIsAllDay] = useState(() => { + return event?.start?.type === 'DATE'; + }); + + // Calculate organizer + const organizer: IcsOrganizer | undefined = + event?.organizer || + (user?.email + ? { email: user.email, name: user.email.split('@')[0] } + : undefined); + + // Check if current user is an attendee (invited to this event) + const currentUserAttendee = event?.attendees?.find( + (att) => user?.email && att.email.toLowerCase() === user.email.toLowerCase() + ); + const isInvited = !!( + event?.organizer && + currentUserAttendee && + event.organizer.email !== user?.email + ); + const currentParticipationStatus = + currentUserAttendee?.partstat || 'NEEDS-ACTION'; + + // Reset form when event changes + useEffect(() => { + setTitle(event?.summary || ""); + setDescription(event?.description || ""); + setLocation(event?.location || ""); + setSelectedCalendarUrl(calendarUrl); + + // Initialize attendees from event + if (event?.attendees && event.attendees.length > 0) { + setAttendees(event.attendees); + setShowAttendees(true); + } else { + setAttendees([]); + setShowAttendees(false); + } + + // Initialize recurrence from event + if (event?.recurrenceRule) { + setRecurrence(event.recurrenceRule); + setShowRecurrence(true); + } else { + setRecurrence(undefined); + setShowRecurrence(false); + } + + // Initialize all-day from event + const eventIsAllDay = event?.start?.type === 'DATE'; + setIsAllDay(eventIsAllDay); + + // Parse start/end dates + // Dates from adapter have timezone info and are "fake UTC" - use getUTC* methods + if (event?.start?.date) { + const startDate = + event.start.date instanceof Date + ? event.start.date + : new Date(event.start.date); + // If there's timezone info, the date is "fake UTC" + const isFakeUtc = Boolean(event.start.local?.timezone); + + if (eventIsAllDay) { + setStartDateTime(formatDateLocal(startDate, isFakeUtc)); + } else { + setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc)); + } + } else { + if (eventIsAllDay) { + setStartDateTime(formatDateLocal(new Date())); + } else { + setStartDateTime(formatDateTimeLocal(new Date())); + } + } + + if (event?.end?.date) { + const endDate = + event.end.date instanceof Date + ? event.end.date + : new Date(event.end.date); + const isFakeUtc = Boolean(event.end.local?.timezone); + + if (eventIsAllDay) { + // For all-day events, the end date in ICS is exclusive (next day) + // But in the UI we want to show the inclusive end date (same day for 1-day event) + // So subtract 1 day when displaying + const displayEndDate = new Date(endDate); + displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1); + setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc)); + } else { + setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc)); + } + } else { + if (eventIsAllDay) { + // Default: same day + setEndDateTime(formatDateLocal(new Date())); + } else { + // Default: 1 hour after start + const defaultEnd = new Date(); + defaultEnd.setHours(defaultEnd.getHours() + 1); + setEndDateTime(formatDateTimeLocal(defaultEnd)); + } + } + }, [event, calendarUrl]); + + const handleSave = async () => { + setIsLoading(true); + try { + // Remove duration to avoid union type conflict + // (IcsEvent is either end or duration, not both) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { duration: _duration, ...eventWithoutDuration } = event ?? {}; + + let icsEvent: IcsEvent; + + if (isAllDay) { + // All-day event + const startDate = parseDateLocal(startDateTime); + const endDate = parseDateLocal(endDateTime); + + // Create UTC dates for all-day events + const utcStart = new Date( + Date.UTC( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + ) + ); + // For all-day events, the end date in ICS is exclusive (next day) + // The user enters the inclusive end date, so we add 1 day for ICS + const utcEnd = new Date( + Date.UTC( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate() + 1 // Add 1 day for exclusive end + ) + ); + + icsEvent = { + ...eventWithoutDuration, + uid: event?.uid || crypto.randomUUID(), + summary: title, + description: description || undefined, + location: location || undefined, + stamp: event?.stamp || { date: new Date() }, + start: { + date: utcStart, + type: "DATE", + }, + end: { + date: utcEnd, + type: "DATE", + }, + organizer: organizer, + attendees: attendees.length > 0 ? attendees : undefined, + recurrenceRule: recurrence, + }; + } else { + // Timed event + const startDate = parseDateTimeLocal(startDateTime); + const endDate = parseDateTimeLocal(endDateTime); + + // Create "fake UTC" dates where getUTCHours() = local hours + // This is required because ts-ics uses getUTCHours() to generate ICS + const fakeUtcStart = new Date( + Date.UTC( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate(), + startDate.getHours(), + startDate.getMinutes(), + startDate.getSeconds() + ) + ); + const fakeUtcEnd = new Date( + Date.UTC( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate(), + endDate.getHours(), + endDate.getMinutes(), + endDate.getSeconds() + ) + ); + + icsEvent = { + ...eventWithoutDuration, + uid: event?.uid || crypto.randomUUID(), + summary: title, + description: description || undefined, + location: location || undefined, + stamp: event?.stamp || { date: new Date() }, + start: { + date: fakeUtcStart, + type: "DATE-TIME", + local: { + date: fakeUtcStart, + timezone: BROWSER_TIMEZONE, + tzoffset: adapter.getTimezoneOffset(startDate, BROWSER_TIMEZONE), + }, + }, + end: { + date: fakeUtcEnd, + type: "DATE-TIME", + local: { + date: fakeUtcEnd, + timezone: BROWSER_TIMEZONE, + tzoffset: adapter.getTimezoneOffset(endDate, BROWSER_TIMEZONE), + }, + }, + organizer: organizer, + attendees: attendees.length > 0 ? attendees : undefined, + recurrenceRule: recurrence, + }; + } + + await onSave(icsEvent, selectedCalendarUrl); + onClose(); + } catch (error) { + console.error("Failed to save event:", error); + alert(t('api.error.unexpected')); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteClick = () => { + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async (option?: RecurringDeleteOption) => { + if (!onDelete || !event?.uid) return; + + setShowDeleteModal(false); + setIsLoading(true); + try { + await onDelete(event as IcsEvent, selectedCalendarUrl, option); + onClose(); + } catch (error) { + console.error("Failed to delete event:", error); + alert(t('api.error.unexpected')); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteCancel = () => { + setShowDeleteModal(false); + }; + + const handleRespondToInvitation = async ( + status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED' + ) => { + if (!onRespondToInvitation || !event) return; + + setIsLoading(true); + try { + await onRespondToInvitation(event as IcsEvent, status); + // Update local state to reflect new status + setAttendees((prev) => + prev.map((att) => + user?.email && att.email.toLowerCase() === user.email.toLowerCase() + ? { ...att, partstat: status } + : att + ) + ); + } catch (error) { + console.error("Failed to respond to invitation:", error); + alert(t('api.error.unexpected')); + } finally { + setIsLoading(false); + } + }; + + const handleStartDateChange = (e: React.ChangeEvent) => { + const newStartValue = e.target.value; + + if (isAllDay) { + const oldStart = parseDateLocal(startDateTime); + const oldEnd = parseDateLocal(endDateTime); + const duration = oldEnd.getTime() - oldStart.getTime(); + + const newStart = parseDateLocal(newStartValue); + const newEnd = new Date(newStart.getTime() + duration); + + setStartDateTime(newStartValue); + setEndDateTime(formatDateLocal(newEnd)); + } else { + const oldStart = parseDateTimeLocal(startDateTime); + const oldEnd = parseDateTimeLocal(endDateTime); + const duration = oldEnd.getTime() - oldStart.getTime(); + + const newStart = parseDateTimeLocal(newStartValue); + const newEnd = new Date(newStart.getTime() + duration); + + setStartDateTime(newStartValue); + setEndDateTime(formatDateTimeLocal(newEnd)); + } + }; + + const handleAllDayChange = (e: React.ChangeEvent) => { + const newIsAllDay = e.target.checked; + setIsAllDay(newIsAllDay); + + // Convert dates when switching modes + if (newIsAllDay) { + // Convert to date-only format + const start = parseDateTimeLocal(startDateTime); + const end = parseDateTimeLocal(endDateTime); + setStartDateTime(formatDateLocal(start)); + setEndDateTime(formatDateLocal(end)); + } else { + // Convert to datetime format + const start = parseDateLocal(startDateTime); + const end = parseDateLocal(endDateTime); + setStartDateTime(formatDateTimeLocal(start)); + setEndDateTime(formatDateTimeLocal(end)); + } + }; + + return ( + <> + + {t('calendar.event.delete')} + + ) : undefined + } + rightActions={ + <> + + + + } + > +
+ setTitle(e.target.value)} + fullWidth + /> + + {/* Invitation Response Section */} + {isInvited && mode === 'edit' && onRespondToInvitation && ( +
+
+ + {t('calendar.event.invitation', { defaultValue: 'Invitation' })} + + + {t('calendar.event.organizedBy', { + defaultValue: 'Organisé par', + })}{' '} + {event?.organizer?.name || event?.organizer?.email} + +
+
+ + + +
+ {currentParticipationStatus && ( +
+ {t('calendar.event.yourResponse', { + defaultValue: 'Votre réponse', + })} + :{' '} + + {currentParticipationStatus === 'ACCEPTED' && + t('calendar.event.accepted', { defaultValue: 'Accepté' })} + {currentParticipationStatus === 'TENTATIVE' && + t('calendar.event.tentative', { + defaultValue: 'Peut-être', + })} + {currentParticipationStatus === 'DECLINED' && + t('calendar.event.declined', { defaultValue: 'Refusé' })} + {currentParticipationStatus === 'NEEDS-ACTION' && + t('calendar.event.needsAction', { + defaultValue: 'En attente', + })} + +
+ )} +
+ )} + + + {t('calendar.event.allDay')} + + +
+ + setEndDateTime(e.target.value)} + fullWidth + /> +
+ + setLocation(e.target.value)} + fullWidth + /> + +