Files
workavia-calendar-front/src/features/Events/EventModal.tsx
T
2026-03-13 09:49:36 +01:00

966 lines
32 KiB
TypeScript

import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { ResponsiveDialog } from "@/components/Dialog";
import EventFormFields from "@/components/Event/EventFormFields";
import { addDays } from "@/components/Event/utils/dateRules";
import {
formatDateTimeInTimezone,
formatLocalDateTime,
} from "@/components/Event/utils/dateTimeFormatters";
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
import { assertThunkSuccess } from "@/utils/assertThunkSuccess";
import {
buildEventFormTempData,
clearEventFormTempData,
EventFormState,
restoreEventFormDataFromTemp as restoreEventFormDataFromStorage,
restoreFormDataFromTemp,
saveEventFormDataToTemp,
showErrorNotification,
} from "@/utils/eventFormTempStorage";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import {
browserDefaultTimeZone,
getTimezoneOffset,
resolveTimezone,
} from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import { addVideoConferenceToDescription } from "@/utils/videoConferenceUtils";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import { Box, Button } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import React, {
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useI18n } from "twake-i18n";
import { Calendar } from "../Calendars/CalendarTypes";
import { putEventAsync } from "../Calendars/services";
import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { buildDelegatedEventURL } from "./eventUtils";
import { useEventOrganizer } from "./useEventOrganizer";
function EventPopover({
open,
onClose,
selectedRange,
setSelectedRange,
calendarRef,
event,
}: {
anchorEl: HTMLElement | null;
open: boolean;
onClose: (refresh?: boolean) => void;
selectedRange: DateSelectArg | null;
setSelectedRange: React.Dispatch<React.SetStateAction<DateSelectArg | null>>;
calendarRef: React.RefObject<CalendarApi | null>;
event?: CalendarEvent;
}) {
const dispatch = useAppDispatch();
const { t } = useI18n();
const selectedCalendarIds = useSelectedCalendars();
const userOrganizer = useAppSelector((state) => state.user.organiserData);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calList = useAppSelector((state) => state.calendars.list);
const userPersonalCalendars: Calendar[] = Object.values(calList || {}).filter(
(cal) =>
cal.id?.split("/")[0] === userId || (cal.delegated && cal.access?.write)
);
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(browserDefaultTimeZone);
return { zones, browserTz, getTimezoneOffset };
}, []);
const calendarTimezone = useAppSelector((state) => state.settings.timeZone);
const resolvedCalendarTimezone = useMemo(() => {
const tz = calendarTimezone || browserDefaultTimeZone;
return resolveTimezone(tz);
}, [calendarTimezone]);
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 defaultCalendarId = useMemo(() => {
const selectedAndWritable = Object.values(calList ?? {})
.filter((cal) => selectedCalendarIds.includes(cal.id))
.filter(
(cal) =>
(cal.delegated && cal.access?.write) ||
userId === cal.id?.split("/")[0]
);
if (selectedAndWritable.length > 0) {
return selectedAndWritable[0].id!;
}
const firstPersonal = Object.values(calList ?? {}).find(
(cal) =>
cal.id?.split("/")[0] === userId || (cal.delegated && cal.access?.write)
);
return firstPersonal?.id ?? "";
}, [selectedCalendarIds, calList, userId]);
const [calendarid, setCalendarid] = useState(
event?.calId ?? defaultCalendarId
);
const [allday, setAllDay] = useState(event?.allday ?? false);
const [repetition, setRepetition] = useState<RepetitionObject>(
event?.repetition ?? ({} as RepetitionObject)
);
// Derive the effective organizer based on the selected calendar.
// When a delegated calendar is selected, the organizer must be the
// calendar owner, not the logged-in user.
const { organizer } = useEventOrganizer({
calendarid,
calList,
userOrganizer,
});
const [attendees, setAttendees] = useState<userAttendee[]>(
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) : resolvedCalendarTimezone
);
const [hasVideoConference, setHasVideoConference] = useState(
event?.x_openpass_videoconference ? true : false
);
const [meetingLink, setMeetingLink] = useState<string | null>(
event?.x_openpass_videoconference || null
);
const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const [hasEndDateChanged, setHasEndDateChanged] = useState(false);
// Use ref to track if we've already initialized to avoid infinite loop
const isInitializedRef = useRef(false);
const userPersonalCalendarsRef = useRef(userPersonalCalendars);
// Track when restoring from error to prevent other useEffects from overriding restored data
const isRestoringFromErrorRef = useRef(false);
// Track whether the user has explicitly picked a calendar so we don't override their choice
const isCalendarIdUserSelectedRef = useRef(false);
// Update ref when userPersonalCalendars changes
useEffect(() => {
userPersonalCalendarsRef.current = userPersonalCalendars;
}, [userPersonalCalendars]);
// Sync calendarid whenever defaultCalendarId resolves, unless the user already picked one
useEffect(() => {
if (!isCalendarIdUserSelectedRef.current && defaultCalendarId) {
setCalendarid(defaultCalendarId);
}
}, [defaultCalendarId]);
const resetAllStateToDefault = useCallback(() => {
setShowMore(false);
setShowDescription(false);
setShowRepeat(false);
setTitle("");
setDescription("");
setAttendees([]);
setLocation("");
setStart("");
setEnd("");
if (defaultCalendarId) {
setCalendarid(defaultCalendarId);
}
setAllDay(false);
setRepetition({} as RepetitionObject);
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
setTimezone(resolvedCalendarTimezone);
setHasVideoConference(false);
setMeetingLink(null);
setHasEndDateChanged(false);
}, [resolvedCalendarTimezone, defaultCalendarId]);
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
const shouldSyncFromRangeRef = useRef(true);
const prevOpenRef = useRef(false);
// Sync timezone when modal opens or calendarTimezone changes
useEffect(() => {
// Detect modal opening (transition from closed to open)
const isOpening = open && !prevOpenRef.current;
if (isOpening) {
shouldSyncFromRangeRef.current = true;
setShowValidationErrors(false);
// Set timezone to calendar timezone for new events when opening
const isNewEvent = !event || !event.uid;
if (isNewEvent) {
setTimezone(resolvedCalendarTimezone);
}
}
// Update previous open state
prevOpenRef.current = open;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event?.uid, resolvedCalendarTimezone]);
// Separately sync timezone when calendarTimezone changes while modal is open for new events
useEffect(() => {
if (open && (!event || !event.uid)) {
setTimezone(resolvedCalendarTimezone);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedCalendarTimezone, open, event?.uid]);
// Set start/end times when modal opens for new event creation
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
// Only run when modal opens and not duplicating an event
// Check if event has uid to determine if it's a valid event (not empty object)
if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) {
return;
}
if (selectedRange && selectedRange.start && selectedRange.end) {
// Auto-check allday if selectedRange is all-day
if (selectedRange.allDay) {
setAllDay(true);
}
// selectedRange gives us the visual time displayed on calendar
// Use selectedRange.startStr and endStr if available (from FullCalendar)
if (selectedRange.startStr && selectedRange.endStr) {
// Check if they are strings (from FullCalendar) or need conversion
const startStr =
typeof selectedRange.startStr === "string"
? selectedRange.startStr
: formatLocalDateTime(selectedRange.start);
const endStr =
typeof selectedRange.endStr === "string"
? selectedRange.endStr
: formatLocalDateTime(selectedRange.end);
// Use the string values directly to preserve the displayed time
const startValue = selectedRange.allDay
? startStr.split("T")[0]
: startStr.slice(0, 16); // YYYY-MM-DDTHH:mm
let endValue = selectedRange.allDay
? endStr.split("T")[0]
: endStr.slice(0, 16);
// For all-day slots: detect single click vs drag multiple days
// FullCalendar uses exclusive end, so end date = actual end date + 1
// For UI display, we need to adjust:
// - Single click (end = start + 1): set end = start for UI
// - Drag multiple days (end = actual end + 1): set end = end - 1 for UI
if (selectedRange.allDay) {
const startDateOnly = startValue.slice(0, 10);
const endDateOnlyFromRange = endValue.slice(0, 10);
// Calculate days difference
const startDateObj = new Date(startDateOnly);
const endDateObj = new Date(endDateOnlyFromRange);
const daysDiff = Math.floor(
(endDateObj.getTime() - startDateObj.getTime()) /
(1000 * 60 * 60 * 24)
);
if (daysDiff <= 1) {
// Single click: FullCalendar gives end = start + 1, set end = start for UI
endValue = startValue;
} else {
// Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI
const adjustedEndDate = new Date(endDateObj);
adjustedEndDate.setDate(adjustedEndDate.getDate() - 1);
endValue = adjustedEndDate.toISOString().split("T")[0];
}
}
setStart(startValue);
setEnd(endValue);
// Check if multiple days event
const startDateOnly = startValue.slice(0, 10);
const endDateOnly = endValue.slice(0, 10);
const isMultipleDays = startDateOnly !== endDateOnly;
if (isMultipleDays) {
// Keep normal mode (showMore = false) for multiple days events
setShowMore(false);
if (selectedRange.allDay) {
// Dragged from allday slot: allday already set to true at line 234
// Will show start date and end date (handled by showEndDate logic)
} else {
// Dragged from week/month view grid: keep allday=false to show time fields, trigger 4 fields display
setHasEndDateChanged(true);
}
}
} else {
// Fallback: format Date objects using local time components
// Only set if both start and end are valid
const formattedStart = formatLocalDateTime(selectedRange.start);
let formattedEnd = formatLocalDateTime(selectedRange.end);
// For all-day slots: detect single click vs drag multiple days
// FullCalendar uses exclusive end, so end date = actual end date + 1
// For UI display, we need to adjust:
// - Single click (end = start + 1): set end = start for UI
// - Drag multiple days (end = actual end + 1): set end = end - 1 for UI
if (selectedRange.allDay && formattedStart && formattedEnd) {
const startDateOnly = formattedStart.slice(0, 10);
const endDateOnly = formattedEnd.slice(0, 10);
// Calculate days difference
const startDateObj = new Date(startDateOnly);
const endDateObj = new Date(endDateOnly);
const daysDiff = Math.floor(
(endDateObj.getTime() - startDateObj.getTime()) /
(1000 * 60 * 60 * 24)
);
if (daysDiff <= 1) {
// Single click: FullCalendar gives end = start + 1, set end = start for UI
formattedEnd = formattedStart;
} else {
// Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI
const adjustedEndDate = new Date(endDateObj);
adjustedEndDate.setDate(adjustedEndDate.getDate() - 1);
const adjustedEndDateStr = adjustedEndDate
.toISOString()
.split("T")[0];
// Preserve time part if exists, otherwise use date only
formattedEnd = formattedEnd.includes("T")
? `${adjustedEndDateStr}T${formattedEnd.split("T")[1]}`
: adjustedEndDateStr;
}
}
if (formattedStart) setStart(formattedStart);
if (formattedEnd) setEnd(formattedEnd);
if (formattedStart && formattedEnd) {
const startDateOnly = formattedStart.slice(0, 10);
const endDateOnly = formattedEnd.slice(0, 10);
const isMultipleDays = startDateOnly !== endDateOnly;
if (isMultipleDays) {
// Keep normal mode (showMore = false) for multiple days events
setShowMore(false);
if (selectedRange.allDay) {
// Dragged from allday slot: allday already set to true at line 234
// Will show start date and end date (handled by showEndDate logic)
} else {
// Dragged from week/month view grid: keep allday=false to show time fields, trigger 4 fields display
setHasEndDateChanged(true);
}
}
}
}
} else {
// No valid selectedRange - use default times
// Start time = current time + 1 hour (rounded up to the hour)
// End time = start time + 1 hour
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(now.getHours() + 1);
nextHour.setMinutes(0);
nextHour.setSeconds(0);
nextHour.setMilliseconds(0);
const endTime = new Date(nextHour);
endTime.setHours(nextHour.getHours() + 1);
// Format using local time (browser timezone)
const formattedStart = formatLocalDateTime(nextHour);
const formattedEnd = formatLocalDateTime(endTime);
if (formattedStart) setStart(formattedStart);
if (formattedEnd) setEnd(formattedEnd);
// Default to non-all-day when no selectedRange
setAllDay(false);
}
shouldSyncFromRangeRef.current = false;
}, [selectedRange, open, event]);
// Initialize state when event prop changes (duplicate event or tempEvent with attendees)
useEffect(() => {
if (event && event.uid) {
// Editing existing event - populate fields with event data
setTitle(event.title ?? "");
setDescription(event.description ?? "");
setLocation(event.location ?? "");
// Handle all-day events properly
const isAllDay = event.allday ?? false;
setAllDay(isAllDay);
// Get event's timezone for formatting - prioritize event.timezone from server
let eventTimezone: string;
if (event.timezone) {
eventTimezone = resolveTimezone(event.timezone);
} else {
eventTimezone = resolvedCalendarTimezone;
}
// Format dates based on all-day status and timezone
if (event.start) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const startDate = new Date(event.start);
setStart(startDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's timezone
setStart(formatDateTimeInTimezone(event.start, eventTimezone));
}
} else {
setStart("");
}
if (event.end) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const endDate = new Date(event.end);
setEnd(endDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's timezone
setEnd(formatDateTimeInTimezone(event.end, eventTimezone));
}
} else {
setEnd("");
}
if (defaultCalendarId) {
setCalendarid(defaultCalendarId);
}
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(eventTimezone);
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);
}
}
} else if (event && event.attendee && event.attendee.length > 0) {
// Handle tempEvent case (no uid but has attendees from temp calendar search)
setAttendees(
event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
);
}
}, [
event,
organizer?.cal_address,
resolvedCalendarTimezone,
defaultCalendarId,
]);
// Reset state when creating new event (event is empty object or undefined)
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
const isCreatingNew = !event || !event.uid;
const wasInitialized = isInitializedRef.current;
if (isCreatingNew && wasInitialized) {
// Creating new event - reset all fields to default
// Note: start and end are handled by the selectedRange useEffect
// Note: timezone is handled by separate useEffect above
setShowMore(false);
setShowDescription(false);
setShowRepeat(false);
setTitle("");
setDescription("");
setAttendees([]);
setLocation("");
if (defaultCalendarId) {
setCalendarid(defaultCalendarId);
}
setAllDay(false);
setRepetition({} as RepetitionObject);
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
setHasVideoConference(false);
setMeetingLink(null);
setHasEndDateChanged(false);
}
if (!isCreatingNew) {
isInitializedRef.current = true;
}
}, [event, defaultCalendarId]);
const handleStartChange = useCallback(
(newStart: string) => {
setStart(newStart);
// Defer visual feedback (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
start: new Date(newStart),
startStr: newStart,
allDay: allday,
} as DateSelectArg;
setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[allday]
);
const handleEndChange = useCallback(
(newEnd: string) => {
setEnd(newEnd);
// Defer visual feedback (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
end: new Date(newEnd),
endStr: newEnd,
allDay: allday,
} as DateSelectArg;
setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[allday]
);
const handleAllDayChange = useCallback(
(newAllDay: boolean, newStart: string, newEnd: string) => {
// Update critical state immediately (checkbox response)
setAllDay(newAllDay);
setStart(newStart);
setEnd(newEnd);
// Defer visual feedback updates (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
startStr: newAllDay ? newStart.split("T")[0] : newStart,
endStr: newAllDay ? newEnd.split("T")[0] : newEnd,
start: new Date(
newAllDay ? newStart.split("T")[0] + "T00:00:00" : newStart
),
end: new Date(
newAllDay ? newEnd.split("T")[0] + "T00:00:00" : newEnd
),
allDay: newAllDay,
} as DateSelectArg;
setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Wrap setCalendarid so we can track explicit user selections
const handleCalendaridChange = useCallback((id: string) => {
isCalendarIdUserSelectedRef.current = true;
setCalendarid(id);
}, []);
const handleClose = () => {
// Clear temp data when user manually closes modal
clearEventFormTempData("create");
onClose(false);
setShowValidationErrors(false);
resetAllStateToDefault();
setStart("");
setEnd("");
shouldSyncFromRangeRef.current = true; // Reset for next time
isCalendarIdUserSelectedRef.current = false; // Reset so next open gets fresh default
};
// Function to save current form data to temp storage
const saveCurrentFormData = useCallback(() => {
const formState: EventFormState = {
title,
description,
location,
start,
end,
allday,
repetition,
attendees,
alarm,
busy,
eventClass,
timezone,
calendarid,
hasVideoConference,
meetingLink,
showMore,
showDescription,
showRepeat,
hasEndDateChanged,
};
return buildEventFormTempData(formState);
}, [
title,
description,
location,
start,
end,
allday,
repetition,
attendees,
alarm,
busy,
eventClass,
timezone,
calendarid,
hasVideoConference,
meetingLink,
showMore,
showDescription,
showRepeat,
hasEndDateChanged,
]);
// Check for temp data when modal opens
useEffect(() => {
if (open && !event?.uid) {
// Only restore for new events (not duplicating)
const tempData = restoreEventFormDataFromStorage("create");
if (tempData && tempData.fromError) {
// Mark that we're restoring from error to prevent other useEffects from overriding
isRestoringFromErrorRef.current = true;
// Prevent selectedRange useEffect from running
shouldSyncFromRangeRef.current = false;
// Restore form data from previous failed save
restoreFormDataFromTemp(tempData, {
setTitle,
setDescription,
setLocation,
setStart,
setEnd,
setAllDay,
setRepetition,
setAttendees,
setAlarm,
setBusy,
setEventClass,
setTimezone,
setCalendarid,
setHasVideoConference,
setMeetingLink,
setShowMore,
setShowDescription,
setShowRepeat,
setHasEndDateChanged,
});
// Clear the error flag but keep data until successful save
const updatedTempData = { ...tempData, fromError: false };
saveEventFormDataToTemp("create", updatedTempData);
// Reset flag after restore completes (use setTimeout to ensure other useEffects have checked the flag)
setTimeout(() => {
isRestoringFromErrorRef.current = false;
}, 0);
}
}
}, [open, event?.uid]);
const handleSave = async () => {
// Show validation errors when Save is clicked
setShowValidationErrors(true);
// Check if form is valid before saving
if (!isFormValid) {
return;
}
const newEventUID = crypto.randomUUID();
// Resolve target calendar safely
const targetCalendar: Calendar | undefined =
calList[calendarid] ||
userPersonalCalendars[0] ||
(Object.values(calList)[0] as Calendar | undefined);
if (!targetCalendar || !targetCalendar.id) {
console.error("No target calendar available to save event");
return;
}
const newEventURL = `/calendars/${targetCalendar.id}/${newEventUID}.ics`;
const newEvent: CalendarEvent = {
calId: targetCalendar.id,
title,
URL: targetCalendar.delegated
? buildDelegatedEventURL(targetCalendar, newEventURL)
: newEventURL,
start: "",
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,
sequence: 1,
color: targetCalendar?.color,
alarm: { trigger: alarm, action: "EMAIL" },
x_openpass_videoconference: meetingLink || undefined,
};
if (allday) {
const startDateOnly = (start || "").split("T")[0];
const endDateOnlyUI = (end || start || "").split("T")[0];
// For all-day events, API needs end date = UI end date + 1 day
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
// Parse date string and create Date at UTC midnight to avoid timezone offset issues
const [startYear, startMonth, startDay] = startDateOnly
.split("-")
.map(Number);
const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number);
const startDateObj = new Date(
Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
);
const endDateObj = new Date(
Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
);
newEvent.start = startDateObj.toISOString();
newEvent.end = endDateObj.toISOString();
} else {
newEvent.start = convertFormDateTimeToISO(start, timezone);
if (end) {
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
const startDateOnly = (start || "").split("T")[0];
const endDateOnly = (end || "").split("T")[0];
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
newEvent.end = convertFormDateTimeToISO(endDateTime, timezone);
} else {
// Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
newEvent.end = convertFormDateTimeToISO(end, timezone);
}
}
}
if (attendees.length > 0) {
newEvent.attendee = newEvent.attendee.concat(attendees);
}
// Reset validation state when validation passes
setShowValidationErrors(false);
// Save current form data to temp storage before closing
const formDataToSave = saveCurrentFormData();
saveEventFormDataToTemp("create", formDataToSave);
// Close popup immediately
onClose(true);
// Save to API in background
try {
const result = await dispatch(
putEventAsync({
cal: targetCalendar,
newEvent,
})
);
await assertThunkSuccess(result);
// Clear temp data on successful save
clearEventFormTempData("create");
// Reset all state to default values only on successful save
resetAllStateToDefault();
} catch (error) {
const errorObj = error as { message?: string };
// API failed - restore form data and mark as error
const errorFormData = {
...formDataToSave,
fromError: true,
};
saveEventFormDataToTemp("create", errorFormData);
// Show error notification
showErrorNotification(
errorObj.message || "Failed to create event. Please try again."
);
// Try to reopen modal by dispatching custom event
// Parent component should listen to this event and reopen modal
window.dispatchEvent(
new CustomEvent("eventModalError", {
detail: { type: "create" },
})
);
}
};
const dialogActions = (
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
{!showMore && (
<Button startIcon={<AddIcon />} onClick={() => setShowMore(!showMore)}>
{t("common.moreOptions")}
</Button>
)}
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
{showMore && (
<Button variant="outlined" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button variant="contained" onClick={handleSave}>
{t("actions.save")}
</Button>
</Box>
</Box>
);
return (
<ResponsiveDialog
open={open}
onClose={handleClose}
title={
event?.uid
? t("eventDuplication.duplicateEvent")
: t("event.createEvent")
}
isExpanded={showMore}
onExpandToggle={() => setShowMore(!showMore)}
actions={dialogActions}
>
<EventFormFields
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
location={location}
setLocation={setLocation}
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allday={allday}
setAllDay={setAllDay}
repetition={repetition}
setRepetition={setRepetition}
attendees={attendees}
setAttendees={setAttendees}
alarm={alarm}
setAlarm={setAlarm}
busy={busy}
setBusy={setBusy}
eventClass={eventClass}
setEventClass={setEventClass}
timezone={timezone}
setTimezone={setTimezone}
calendarid={calendarid}
setCalendarid={handleCalendaridChange}
hasVideoConference={hasVideoConference}
setHasVideoConference={setHasVideoConference}
meetingLink={meetingLink}
setMeetingLink={setMeetingLink}
showMore={showMore}
showDescription={showDescription}
setShowDescription={setShowDescription}
showRepeat={showRepeat}
setShowRepeat={setShowRepeat}
isOpen={open}
userPersonalCalendars={userPersonalCalendars}
timezoneList={timezoneList}
onStartChange={handleStartChange}
onEndChange={handleEndChange}
onAllDayChange={handleAllDayChange}
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
/>
</ResponsiveDialog>
);
}
export default EventPopover;