import { useAppDispatch, useAppSelector } from '@/app/hooks' import EventPopover from '@/features/Events/EventModal' import EventPreviewModal from '@/features/Events/EventPreview' import { CalendarEvent } from '@/features/Events/EventsTypes' import ImportAlert from '@/features/Events/ImportAlert' import SearchResultsPage from '@/features/Search/SearchResultsPage' import { setTimeZone } from '@/features/Settings/SettingsSlice' import { useScreenSizeDetection } from '@/useScreenSizeDetection' import { setDisplayedDateAndRange } from '@/utils/CalendarRangeManager' import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' import { setSelectedCalendars as setSelectedCalendarsToStorage } from '@/utils/storage/setSelectedCalendars' import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars' import { browserDefaultTimeZone } from '@/utils/timezone' import type { EventApi, LocaleInput, MoreLinkArg } from '@fullcalendar/core' import { CalendarApi, DateSelectArg } from '@fullcalendar/core' import frLocale from '@fullcalendar/core/locales/fr' import ruLocale from '@fullcalendar/core/locales/ru' import viLocale from '@fullcalendar/core/locales/vi' import dayGridPlugin from '@fullcalendar/daygrid' import interactionPlugin from '@fullcalendar/interaction' import momentTimezonePlugin from '@fullcalendar/moment-timezone' import FullCalendar from '@fullcalendar/react' import timeGridPlugin from '@fullcalendar/timegrid' import { Fab } from '@linagora/twake-mui' import AddIcon from '@mui/icons-material/Add' import moment from 'moment-timezone' import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import { useI18n } from 'twake-i18n' import { useCalendarDataLoader } from '../../features/Calendars/useCalendarLoader' import { User } from '../Attendees/types' import { EventErrorSnackbar } from '../Error/ErrorSnackbar' import { EventErrorHandler } from '../Error/EventErrorHandler' import { EditModeDialog } from '../Event/EditModeDialog' import { Menubar, MenubarProps } from '../Menubar/Menubar' import './Calendar.styl' import './CustomCalendar.styl' import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' import { TimezoneSelector } from './TimezoneSelector' import { eventToFullCalendarFormat, extractEvents, updateSlotLabelVisibility } from './utils/calendarUtils' import { CALENDAR_VIEWS } from './utils/constants' import Sidebar from './Sidebar/SideBar' import TempSearchDialog from './TempSearchDialog' import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice' import ViewMoreEvents from './ViewMoreEvents' const localeMap: Record = { fr: frLocale, ru: ruLocale, vi: viLocale, en: undefined } interface CalendarAppProps { calendarRef: MutableRefObject onDateChange?: (date: Date) => void onViewChange: (view: string) => void menubarProps?: MenubarProps openSidebar: boolean onCloseSidebar: () => void setCurrentView: (view: string) => void currentView: string } const CalendarApp: React.FC = ({ calendarRef, onDateChange, onViewChange, menubarProps, openSidebar, onCloseSidebar, setCurrentView, currentView }: CalendarAppProps) => { const [selectedDate, setSelectedDate] = useState(new Date()) const [debouncedDate, setDebouncedDate] = useState(new Date()) useEffect(() => { const t = setTimeout(() => setDebouncedDate(selectedDate), 300) return (): void => clearTimeout(t) }, [selectedDate]) const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()) const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? '' const dispatch = useAppDispatch() const view = useAppSelector(state => state.settings.view) const userData = useAppSelector(state => state.user.userData) const workingDays = useAppSelector( state => state.settings.businessHours?.daysOfWeek ) const hideWorkingDays = useAppSelector(state => state.settings.workingDays) const hideDeclinedEvents = useAppSelector( state => state.settings.hideDeclinedEvents ) const { isTablet, isTooSmall: isMobile } = useScreenSizeDetection() const hiddenDays = useMemo(() => { if (!hideWorkingDays || !workingDays || workingDays.length === 0) return [] const validWorkingDays = workingDays.filter(d => d >= 0 && d <= 6) if (validWorkingDays.length === 0) return [] return [0, 1, 2, 3, 4, 5, 6].filter(d => !validWorkingDays.includes(d)) }, [hideWorkingDays, workingDays]) const calendars = useAppSelector(state => state.calendars.list) const isPending = useAppSelector(state => state.calendars.pending) const displayWeekNumbers = useAppSelector( state => state.settings.displayWeekNumbers ) const tempcalendars = useAppSelector(state => state.calendars.templist) const storedCalendars = useSelectedCalendars() const [selectedCalendars, setSelectedCalendars] = useState(storedCalendars) const calendarIdsString = useMemo( () => Object.keys(calendars || {}) .sort() .join(','), [calendars] ) const calendarIds = useMemo( () => (calendarIdsString ? calendarIdsString.split(',') : []), [calendarIdsString] ) const timezone = useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone // Auto-select personal calendars when first loaded const initialLoadRef = useRef(true) const [eventErrors, setEventErrors] = useState([]) const errorHandler = useRef(new EventErrorHandler()) useEffect(() => { const handler = errorHandler.current handler.setErrorCallback(setEventErrors) return (): void => handler.setErrorCallback(() => {}) }, []) const handleErrorClose = (): void => { setEventErrors([]) errorHandler.current.clearAll() } useEffect(() => { const updateSelectedCalendars = (): void => { if (initialLoadRef.current && calendarIds.length > 0 && userId) { const cached = localStorage.getItem('selectedCalendars') if (cached && cached.length > 0) { const parsed = JSON.parse(cached) as string[] const valid = parsed.filter(id => calendars[id]) setSelectedCalendars(valid) } else { const personalCalendarIds = calendarIds.filter( id => extractEventBaseUuid(id) === userId ) setSelectedCalendars(personalCalendarIds) } initialLoadRef.current = false } } updateSelectedCalendars() }, [calendarIds, calendars, userId]) // Save selected cals to cache useEffect(() => { if (calendarIds.length > 0) { setSelectedCalendarsToStorage(selectedCalendars) } }, [selectedCalendars, calendarIds.length]) useEffect(() => { const updateSelectedCalendarsOnCalendarChange = (): void => { if (calendarIds.length === 0) return const validCalendarIds = new Set(calendarIds) setSelectedCalendars(prev => { const filtered = prev.filter(calId => validCalendarIds.has(calId)) if (filtered.length === prev.length) { const unchanged = filtered.every((id, index) => id === prev[index]) if (unchanged) { return prev } } return filtered }) } updateSelectedCalendarsOnCalendarChange() }, [calendarIds]) const sortedSelectedCalendars = useMemo( () => [...selectedCalendars].sort(), [selectedCalendars] ) const tempCalendarIdsString = useMemo( () => Object.keys(tempcalendars || {}) .sort() .join(','), [tempcalendars] ) const tempCalendarIds = useMemo( () => (tempCalendarIdsString ? tempCalendarIdsString.split(',') : []), [tempCalendarIdsString] ) useCalendarDataLoader({ selectedDate: debouncedDate, currentView, selectedCalendars, sortedSelectedCalendars, calendarIds, calendarIdsString, tempCalendarIds }) const filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars || {}, userData?.email, hideDeclinedEvents ) const filteredTempEvents: CalendarEvent[] = extractEvents( tempCalendarIds, tempcalendars || {}, userData?.email, hideDeclinedEvents ) const [anchorEl, setAnchorEl] = useState(null) const [openEventDisplay, setOpenEventDisplay] = useState(false) const [eventDisplayedId, setEventDisplayedId] = useState('') const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false) const [eventDisplayedCalId, setEventDisplayedCalId] = useState('') // Listen for eventModalError event to reopen modal on API failure useEffect(() => { const handleEventModalError = (e: Event): void => { const event = e as CustomEvent<{ type: 'create' | 'update' eventId: string calId: string typeOfAction: string }> if (event.detail?.type === 'create') { // Reopen create event modal setAnchorEl(document.body) } else if (event.detail?.type === 'update') { // Store update modal info to sessionStorage for EventDisplayPreview to pick up try { sessionStorage.setItem( 'eventUpdateModalReopen', JSON.stringify({ eventId: event.detail.eventId, calId: event.detail.calId, typeOfAction: event.detail.typeOfAction, timestamp: Date.now() }) ) // Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage if ( !openEventDisplay || eventDisplayedId !== event.detail.eventId || eventDisplayedCalId !== event.detail.calId ) { setEventDisplayedId(event.detail.eventId) setEventDisplayedCalId(event.detail.calId) setEventDisplayedTemp(false) setOpenEventDisplay(true) } else { // If EventDisplayPreview is already open, trigger reopen by dispatching a custom event window.dispatchEvent( new CustomEvent('eventUpdateModalReopen', { detail: { eventId: event.detail.eventId, calId: event.detail.calId, typeOfAction: event.detail.typeOfAction } }) ) } } catch { // Ignore sessionStorage errors } } } window.addEventListener( 'eventModalError', handleEventModalError as EventListener ) return (): void => { window.removeEventListener( 'eventModalError', handleEventModalError as EventListener ) } }, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]) const [openEditModePopup, setOpenEditModePopup] = useState( null ) const [, setTypeOfAction] = useState<'solo' | 'all' | undefined>(undefined) const [afterChoiceFunc, setAfterChoiceFunc] = useState< ((type: 'solo' | 'all' | undefined) => void) | undefined >() const [, setSelectedEvent] = useState({} as CalendarEvent) const [selectedRange, setSelectedRange] = useState(null) const [tempUsers, setTempUsers] = useState([]) const [tempEvent, setTempEvent] = useState({} as CalendarEvent) const [isMoreEventsDrawerOpen, setIsMoreEventsDrawerOpen] = useState(false) const [moreEvents, setMoreEvents] = useState([]) const handleMoreLinkClick = (arg: MoreLinkArg): string | void => { if (!isMobile) return setMoreEvents(arg.hiddenSegs.map(seg => seg.event)) setIsMoreEventsDrawerOpen(true) return 'none' } useEffect(() => { if (view !== 'calendar') return const targetView = currentView || (isTablet ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek) if (calendarRef.current?.view.type === targetView) return const id = requestAnimationFrame(() => { if (calendarRef.current?.view.type !== targetView) { calendarRef.current?.changeView(targetView) } }) return (): void => cancelAnimationFrame(id) }, [view, isTablet, currentView, calendarRef]) // Event handlers const eventHandlers = useCalendarEventHandlers({ setSelectedRange, setAnchorEl, calendarRef, dispatch, setOpenEventDisplay, setEventDisplayedId, setEventDisplayedCalId, setEventDisplayedTemp, calendars, setSelectedEvent, setAfterChoiceFunc, setOpenEditModePopup, tempUsers, setTempEvent, timezone }) // View handlers const viewHandlers = useCalendarViewHandlers({ calendarRef, setSelectedDate, setSelectedMiniDate, onViewChange: setCurrentView, calendars, tempcalendars, // Note: To preserve current logic, this will temporarily disable eslint for react-hooks/refs // eslint-disable-next-line react-hooks/refs errorHandler: errorHandler.current }) useEffect(() => { if (process.env.NODE_ENV === 'test') { window.__calendarRef = calendarRef } }, [calendarRef]) const { t, lang } = useI18n() return (
eventHandlers.handleDateSelect(null)} onViewChange={onViewChange} selectedMiniDate={selectedMiniDate} setSelectedMiniDate={setSelectedMiniDate} selectedCalendars={selectedCalendars} setSelectedCalendars={setSelectedCalendars} tempUsers={tempUsers} setTempUsers={setTempUsers} currentView={currentView} onDateChange={onDateChange} />
{menubarProps?.isIframe && } {isTablet && ( eventHandlers.handleDateSelect(null)} sx={{ position: 'fixed', bottom: 24, right: 24, zIndex: 20, borderRadius: '16px' }} > )} {view === 'calendar' && ( { if (ref) { calendarRef.current = ref.getApi() } }} plugins={[ dayGridPlugin, timeGridPlugin, interactionPlugin, momentTimezonePlugin ]} initialView={ currentView || (isTablet ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek) } firstDay={1} editable={true} locale={localeMap[lang]} hiddenDays={hiddenDays} selectable={true} timeZone={timezone} height="100%" select={eventHandlers.handleDateSelect} longPressDelay={100} nowIndicator slotLabelClassNames={arg => [ updateSlotLabelVisibility(new Date(), arg, timezone) ]} nowIndicatorContent={viewHandlers.handleNowIndicatorContent} headerToolbar={false} views={{ timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } } }} dayMaxEvents={true} moreLinkClick={handleMoreLinkClick} events={eventToFullCalendarFormat( filteredEvents, filteredTempEvents, userId, userData?.email, isPending, calendars )} eventOrder={(a: EventApi, b: EventApi) => a.extendedProps.priority - b.extendedProps.priority } weekNumbers={ currentView === CALENDAR_VIEWS.timeGridWeek || currentView === CALENDAR_VIEWS.timeGridDay } weekNumberFormat={{ week: 'long' }} weekNumberContent={arg => { return (
{displayWeekNumbers && (
{t('menubar.views.week')} {arg.num}
)} dispatch(setTimeZone(newTimezone)) } />
) }} dayCellContent={arg => { const month = arg.date.toLocaleDateString(t('locale'), { month: 'short', timeZone: timezone }) if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { return ( {arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText} ) } }} slotDuration="00:30:00" slotLabelInterval="01:00:00" scrollTime="12:00:00" unselectAuto={false} allDayText="" slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} datesSet={arg => { onViewChange?.(arg.view.type) const calendarCurrentDate = calendarRef.current?.getDate() || new Date(arg.start) setDisplayedDateAndRange(calendarCurrentDate) if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { const start = new Date(arg.start).getTime() const end = new Date(arg.end).getTime() const middle = start + (end - start) / 2 setSelectedDate(new Date(middle)) setSelectedMiniDate(calendarCurrentDate) } else { setSelectedDate(calendarCurrentDate) setSelectedMiniDate(calendarCurrentDate) } // Always use the calendar's current date for consistency if (onDateChange) { onDateChange(calendarCurrentDate) } // Notify parent about view change setCurrentView(arg.view.type) // Update slot label visibility when view changes setTimeout(() => { updateSlotLabelVisibility(new Date()) }, 100) }} dayHeaderContent={arg => { const m = moment.tz(arg.date, timezone) const date = m.date() const weekDay = m .toDate() .toLocaleDateString(t('locale'), { weekday: 'short', timeZone: timezone }) .toUpperCase() return (
{weekDay} {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( {date} )}
) }} dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} viewDidMount={viewHandlers.handleViewDidMount} viewWillUnmount={viewHandlers.handleViewWillUnmount} eventClick={eventHandlers.handleEventClick} eventAllow={eventHandlers.handleEventAllow} eventDrop={arg => { void eventHandlers.handleEventDrop(arg) }} eventResize={arg => { void eventHandlers.handleEventResize(arg) }} eventContent={viewHandlers.handleEventContent} eventDidMount={viewHandlers.handleEventDidMount} /> )} {view === 'search' && } { setTypeOfAction(type) if (afterChoiceFunc) { afterChoiceFunc(type) } }} /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( )}
{isMobile && ( <> { dispatch(setIsMobileSearchOpen(false)) onCloseSidebar() }} handleToggleEventPreview={() => eventHandlers.handleDateSelect(null as unknown as DateSelectArg) } /> setIsMoreEventsDrawerOpen(true)} onClose={() => setIsMoreEventsDrawerOpen(false)} moreEvents={moreEvents} handleEventClick={eventHandlers.handleEventClick} /> )}
) } export default CalendarApp