import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import { Checkbox, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography, ToggleButtonGroup, ToggleButton, } from "@mui/material"; import { Description as DescriptionIcon, Public as PublicIcon, Lock as LockIcon, CameraAlt as VideocamIcon, ContentCopy as CopyIcon, Close as DeleteIcon, } from "@mui/icons-material"; import { Box, Button } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import React, { useEffect, useState, useMemo, useCallback, useRef, } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; import { ResponsiveDialog } from "../../components/Dialog"; import { putEventAsync } from "../Calendars/CalendarSlice"; import { Calendars } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/userDataTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { createSelector } from "@reduxjs/toolkit"; import RepeatEvent from "../../components/Event/EventRepeat"; import { TIMEZONES } from "../../utils/timezone-data"; import { generateMeetingLink, addVideoConferenceToDescription, } from "../../utils/videoConferenceUtils"; import { getTimezoneOffset, resolveTimezone, } from "../../components/Calendar/TimezoneSelector"; import { getCalendarRange } from "../../utils/dateUtils"; import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; import { TimezoneAutocomplete } from "../../components/Timezone/TimezoneAutocomplete"; // Helper component for field with label const FieldWithLabel = React.memo( ({ label, isExpanded, children, }: { label: string; isExpanded: boolean; children: React.ReactNode; }) => { if (!isExpanded) { // Normal mode: label on top return ( {label} {children} ); } // Extended mode: label on left return ( {label} {children} ); } ); FieldWithLabel.displayName = "FieldWithLabel"; function EventPopover({ anchorEl, open, onClose, selectedRange, setSelectedRange, calendarRef, event, }: { anchorEl: HTMLElement | null; open: boolean; onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void; selectedRange: DateSelectArg | null; setSelectedRange: Function; calendarRef: React.RefObject; event?: CalendarEvent; }) { const dispatch = useAppDispatch(); const organizer = useAppSelector((state) => state.user.organiserData); const userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const tempList = useAppSelector((state) => state.calendars.templist); const selectPersonnalCalendars = createSelector( (state) => state.calendars, (calendars) => Object.keys(calendars.list) .map((id) => { if (id.split("/")[0] === userId) { return calendars.list[id]; } return {} as Calendars; }) .filter((calendar) => calendar.id) ); const userPersonnalCalendars: Calendars[] = useAppSelector( selectPersonnalCalendars ); const timezoneList = useMemo(() => { const zones = Object.keys(TIMEZONES.zones).sort(); const browserTz = resolveTimezone( Intl.DateTimeFormat().resolvedOptions().timeZone ); return { zones, browserTz }; }, []); const calendarTimezone = useAppSelector((state) => state.calendars.timeZone); const [showMore, setShowMore] = useState(false); const [showDescription, setShowDescription] = useState( event?.description ? true : false ); const [showRepeat, setShowRepeat] = useState( event?.repetition?.freq ? true : false ); const [title, setTitle] = useState(event?.title ?? ""); const [description, setDescription] = useState(event?.description ?? ""); const [location, setLocation] = useState(event?.location ?? ""); const [start, setStart] = useState(event?.start ? event.start : ""); const [end, setEnd] = useState(event?.end ? event.end : ""); const [calendarid, setCalendarid] = useState( event?.calId ? userPersonnalCalendars.findIndex((e) => e.id === event?.calId) : 0 ); const [allday, setAllDay] = useState(event?.allday ?? false); const [repetition, setRepetition] = useState( event?.repetition ?? ({} as RepetitionObject) ); const [attendees, setAttendees] = useState( event?.attendee ? event.attendee.filter((a) => a.cal_address !== organizer.cal_address) : [] ); const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? ""); const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); const [timezone, setTimezone] = useState( event?.timezone ? resolveTimezone(event.timezone) : resolveTimezone(calendarTimezone) ); const [hasVideoConference, setHasVideoConference] = useState( event?.x_openpass_videoconference ? true : false ); const [meetingLink, setMeetingLink] = useState( event?.x_openpass_videoconference || null ); // Use ref to track if we've already initialized to avoid infinite loop const isInitializedRef = useRef(false); const userPersonnalCalendarsRef = useRef(userPersonnalCalendars); // Update ref when userPersonnalCalendars changes useEffect(() => { userPersonnalCalendarsRef.current = userPersonnalCalendars; }, [userPersonnalCalendars]); // Sync timezone with Redux when modal opens or timezone changes useEffect(() => { if (open && !event?.timezone && calendarTimezone) { setTimezone(resolveTimezone(calendarTimezone)); } }, [open, calendarTimezone, event?.timezone]); const resetAllStateToDefault = useCallback(() => { setShowMore(false); setShowDescription(false); setShowRepeat(false); setTitle(""); setDescription(""); setAttendees([]); setLocation(""); setStart(""); setEnd(""); setCalendarid(0); setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); setTimezone(resolveTimezone(calendarTimezone)); setHasVideoConference(false); setMeetingLink(null); }, [calendarTimezone]); useEffect(() => { if (selectedRange) { setStart( selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : "" ); setEnd( selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : "" ); } }, [selectedRange]); // Initialize state when event prop changes useEffect(() => { if (event) { // Editing existing event - populate fields with event data setTitle(event.title ?? ""); setDescription(event.description ?? ""); setLocation(event.location ?? ""); setStart(event.start ? event.start : ""); setEnd(event.end ? event.end : ""); setCalendarid( event.calId ? userPersonnalCalendarsRef.current.findIndex( (e) => e.id === event.calId ) : 0 ); setAllDay(event.allday ?? false); setRepetition(event.repetition ?? ({} as RepetitionObject)); setShowRepeat(event.repetition?.freq ? true : false); setAttendees( event.attendee ? event.attendee.filter( (a) => a.cal_address !== organizer?.cal_address ) : [] ); setAlarm(event.alarm?.trigger ?? ""); setEventClass(event.class ?? "PUBLIC"); setBusy(event.transp ?? "OPAQUE"); setTimezone( event.timezone ? resolveTimezone(event.timezone) : calendarTimezone ); setHasVideoConference(event.x_openpass_videoconference ? true : false); setMeetingLink(event.x_openpass_videoconference || null); // Update description to include video conference footer if exists if (event.x_openpass_videoconference && event.description) { const hasVideoFooter = event.description.includes("Visio:"); if (!hasVideoFooter) { setDescription( addVideoConferenceToDescription( event.description, event.x_openpass_videoconference ) ); } else { setDescription(event.description); } } } }, [event, organizer?.cal_address, calendarTimezone]); // Reset state when creating new event (event is undefined) useEffect(() => { if (!event && isInitializedRef.current) { // Creating new event - reset all fields to default setShowMore(false); setShowDescription(false); setShowRepeat(false); setTitle(""); setDescription(""); setAttendees([]); setLocation(""); setStart(""); setEnd(""); setCalendarid(0); setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); setTimezone(timezoneList.browserTz); setHasVideoConference(false); setMeetingLink(null); } isInitializedRef.current = true; }, [event, calendarTimezone]); const handleAddVideoConference = () => { const newMeetingLink = generateMeetingLink(); const updatedDescription = addVideoConferenceToDescription( description, newMeetingLink ); setDescription(updatedDescription); setHasVideoConference(true); setMeetingLink(newMeetingLink); }; const handleCopyMeetingLink = async () => { if (meetingLink) { try { await navigator.clipboard.writeText(meetingLink); // You could add a toast notification here console.log("Meeting link copied to clipboard"); } catch (err) { console.error("Failed to copy link:", err); } } }; const handleDeleteVideoConference = () => { // Remove video conference footer from description const updatedDescription = description.replace( /\nVisio: https?:\/\/[^\s]+/, "" ); setDescription(updatedDescription); setHasVideoConference(false); setMeetingLink(null); }; const handleClose = () => { onClose({}, "backdropClick"); resetAllStateToDefault(); }; const handleSave = async () => { const newEventUID = crypto.randomUUID(); const newEvent: CalendarEvent = { calId: userPersonnalCalendars[calendarid].id, title, URL: `/calendars/${userPersonnalCalendars[calendarid].id}/${newEventUID}.ics`, start: new Date(start).toISOString(), allday, uid: newEventUID, description, location, class: eventClass, repetition, organizer, timezone, attendee: [ { cn: organizer.cn, cal_address: organizer.cal_address, partstat: "ACCEPTED", rsvp: "FALSE", role: "CHAIR", cutype: "INDIVIDUAL", }, ], transp: busy, color: userPersonnalCalendars[calendarid]?.color, alarm: { trigger: alarm, action: "EMAIL" }, x_openpass_videoconference: meetingLink || undefined, }; if (end) { newEvent.end = new Date(end).toISOString(); } if (attendees.length > 0) { newEvent.attendee = newEvent.attendee.concat(attendees); } // Close popup immediately onClose({}, "backdropClick"); // Reset all state to default values resetAllStateToDefault(); // Save to API in background await dispatch( putEventAsync({ cal: userPersonnalCalendars[calendarid], newEvent, }) ); if (tempList) { const calendarRange = getCalendarRange(new Date(start)); await updateTempCalendar(tempList, newEvent, dispatch, calendarRange); } }; const dialogActions = ( {!showMore && ( )} {showMore && ( )} ); return ( setShowMore(!showMore)} actions={dialogActions} > setTitle(e.target.value)} size="small" margin="dense" /> {!showDescription && ( )} {showDescription && ( setDescription(e.target.value)} size="small" margin="dense" multiline minRows={2} maxRows={10} sx={{ "& .MuiInputBase-root": { maxHeight: "33%", overflowY: "auto", }, "& textarea": { resize: "vertical", }, }} /> )} {showMore && ( Start )} { const newStart = e.target.value; setStart(newStart); // Update selectedRange for visual feedback const startISO = formatLocalDateTime( new Date(newStart), timezone ); const newRange = { ...selectedRange, start: new Date(startISO), startStr: startISO, allDay: allday, }; setSelectedRange(newRange); calendarRef.current?.select(newRange); }} size="small" margin="dense" InputLabelProps={{ shrink: true }} /> {showMore && ( End )} { const newEnd = e.target.value; setEnd(newEnd); // Update selectedRange for visual feedback const endISO = formatLocalDateTime(new Date(newEnd), timezone); const newRange = { ...selectedRange, end: new Date(endISO), endStr: endISO, allDay: allday, }; setSelectedRange(newRange); calendarRef.current?.select(newRange); }} size="small" margin="dense" InputLabelProps={{ shrink: true }} /> { const endDate = new Date(end); const startDate = new Date(start); setAllDay(!allday); if (endDate.getDate() === startDate.getDate()) { endDate.setDate(startDate.getDate() + 1); setEnd(formatLocalDateTime(endDate, timezone)); } const newRange = { ...selectedRange, startStr: allday ? start.split("T")[0] : start, endStr: allday ? endDate.toISOString().split("T")[0] : endDate.toISOString(), start: new Date(allday ? start.split("T")[0] : start), end: new Date( allday ? endDate.toISOString().split("T")[0] : endDate.toISOString() ), allDay: allday, }; setSelectedRange(newRange); }} /> } label="All day" /> { const newShowRepeat = !showRepeat; setShowRepeat(newShowRepeat); if (newShowRepeat) { setRepetition({ freq: "daily", interval: 1, occurrences: 0, endDate: "", selectedDays: [], } as RepetitionObject); } else { setRepetition({ freq: "", interval: 1, occurrences: 0, endDate: "", selectedDays: [], } as RepetitionObject); } }} /> } label="Repeat" /> {showRepeat && ( )} {hasVideoConference && meetingLink && ( <> )} setLocation(e.target.value)} size="small" margin="dense" /> {!showMore && ( Calendar )} {/* Extended options */} {showMore && ( <> { if (newValue !== null) { setEventClass(newValue); } }} size="small" > All Participants )} ); } export default EventPopover; export function formatLocalDateTime(date: Date, timeZone?: string) { const formatter = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone, }); const formatted = formatter.format(date); return formatted.replace(", ", "T"); }