diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index 98546b851b..726bde4aa0 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -15,7 +15,7 @@ import { Machinery, ScheduleSlot, notGuest, - isSameDay, + isSameDayUTC, EventInstance, SlackMentionType } from 'shared'; @@ -502,11 +502,7 @@ export default class CalendarService { if (foundEventType.sendSlackNotifications) { const members = await prisma.user.findMany({ - where: { - userId: { - in: optionalMemberIds.concat(allRequiredMembers) - } - } + where: { userId: { in: optionalMemberIds.concat(allRequiredMembers) } } }); // get the user settings for all the members invited, who are leaderingship @@ -799,13 +795,12 @@ export default class CalendarService { const edittedEvent = eventTransformer(updatedEvent); if (status === Event_Status.SCHEDULED && foundEventType.sendSlackNotifications) { - await sendEventScheduledSlackNotif(updatedEvent.notificationSlackThreads, edittedEvent); + await sendEventScheduledSlackNotif(updatedEvent.notificationSlackThreads, edittedEvent, true); } if (status === Event_Status.CONFIRMED && foundEventType.sendSlackNotifications) { await sendEventConfirmationToThread(updatedEvent.notificationSlackThreads, updatedEvent.userCreated); } - return edittedEvent; } @@ -941,7 +936,11 @@ export default class CalendarService { userCreatedId: true, location: true, dateDeleted: true, - approved: true + approved: true, + status: true, + title: true, + workPackages: true, + scheduledTimes: true } }); @@ -1422,10 +1421,48 @@ export default class CalendarService { if (!event) throw new NotFoundException('Event', eventId); if (event.dateDeleted) throw new DeletedException('Event', eventId); - - // Cannot schedule an already scheduled event if (event.status === Event_Status.SCHEDULED) { - throw new HttpException(400, 'Event is already scheduled'); + const timeSlots = await prisma.schedule_Slot.findMany({ + where: { eventId: event.eventId } + }); + + // Restore the old scheduled time from confirmed members' availabilities + // so they get their time back from the old scheduled event + for (const slot of timeSlots) { + if (!slot.startTime || !slot.endTime) continue; + const startHour = new Date(slot.startTime).getHours(); + const endHour = new Date(slot.endTime).getHours(); + + for (const member of event.confirmedMembers) { + if (!member.drScheduleSettings) continue; + const existingAvailability = member.drScheduleSettings.availabilities.find((a) => + isSameDayUTC(a.dateSet, slot.startTime) + ); + if (!existingAvailability) continue; + // Availability index i represents local hour (10 + i); remove indices that fall within [startHour, endHour) + const returnedAvailability = Array.from({ length: endHour - startHour }, (_, i) => startHour + i - 10).filter( + (i) => i >= 0 + ); + + const updatedAvailability = [...new Set([...existingAvailability.availability, ...returnedAvailability])].sort( + (a, b) => a - b + ); + + await prisma.availability.update({ + where: { availabilityId: existingAvailability.availabilityId }, + data: { availability: updatedAvailability } + }); + } + } + + await prisma.event.update({ + where: { eventId: event.eventId }, + data: { status: Event_Status.SCHEDULED } + }); + + await prisma.schedule_Slot.deleteMany({ + where: { eventId: event.eventId } + }); } // Only the event creator can schedule the event @@ -1462,9 +1499,12 @@ export default class CalendarService { allDay: false } }, + + initialDateScheduled: event.initialDateScheduled ?? null, approved: hasConflict ? Conflict_Status.PENDING : event.approved, approvalRequiredFromUserId: hasConflict ? conflictingEvent?.userCreated.userId : event.approvalRequiredFromUserId }, + ...getEventQueryArgs(organization.organizationId) }); @@ -1474,7 +1514,7 @@ export default class CalendarService { const endHour = endTime.getHours(); for (const member of event.confirmedMembers) { if (!member.drScheduleSettings) continue; - const existingAvailability = member.drScheduleSettings.availabilities.find((a) => isSameDay(a.dateSet, startTime)); + const existingAvailability = member.drScheduleSettings.availabilities.find((a) => isSameDayUTC(a.dateSet, startTime)); if (!existingAvailability) continue; // Availability index i represents local hour (10 + i); remove indices that fall within [startHour, endHour) const updatedAvailability = existingAvailability.availability.filter( @@ -1492,9 +1532,12 @@ export default class CalendarService { }); if (foundEventType?.sendSlackNotifications) { - await sendEventScheduledSlackNotif(updatedEvent.notificationSlackThreads, eventTransformer(updatedEvent)); + await sendEventScheduledSlackNotif( + updatedEvent.notificationSlackThreads, + eventTransformer(updatedEvent), + event.status === Event_Status.SCHEDULED + ); } - return eventTransformer(updatedEvent); } diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 1148d63bd3..c00d1c3423 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -473,9 +473,13 @@ export const sendEventConfirmationToThread = async (threads: SlackMessageThread[ } }; -export const sendEventScheduledSlackNotif = async (threads: SlackMessageThread[], event: Event) => { +export const sendEventScheduledSlackNotif = async ( + threads: SlackMessageThread[], + event: Event, + beingRescheduled: boolean = false +) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod - + const scheduledOrRescheduled = beingRescheduled ? 'rescheduled' : 'scheduled'; // Get work package names const wpNames = event.workPackages.map((wp) => wp.wbsElement.name).join(', '); const drName = event.title + (wpNames ? ` (${wpNames})` : ''); @@ -507,9 +511,12 @@ export const sendEventScheduledSlackNotif = async (threads: SlackMessageThread[] const validSlackIds = resolvedSlackIds.filter((id): id is string => !!id); const mentionPrefix = buildSlackMentionPrefix(SlackMentionType.USER, validSlackIds); - const msg = `:spiral_calendar_pad: ${event.title} for *${drName}* has been scheduled for *${drTime}* ${location} by ${drSubmitter}`; + const msg = + `:spiral_calendar_pad: ${event.title} for *${drName}* has been ` + + scheduledOrRescheduled + + ` for *${drTime}* ${location} by ${drSubmitter}`; const docLink = event.questionDocumentLink ? `<${event.questionDocumentLink}|Doc Link>` : ''; - const threadMsg = `${mentionPrefix}This event has been Scheduled! \n` + docLink; + const threadMsg = `${mentionPrefix}This event has been ` + scheduledOrRescheduled + ` \n` + docLink; if (threads && threads.length !== 0) { const msgs = threads.map((thread) => editMessage(thread.channelId, thread.timestamp, msg)); diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 1a1ca72110..15b8607002 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -65,7 +65,6 @@ const SHOP_KEY = ['shops'] as const; const CALENDAR_KEY = ['calendars'] as const; export const EVENT_TYPE_KEY = ['event-types'] as const; export const EVENT_KEY = ['events'] as const; - export interface EventCreateArgs { title: string; eventTypeId: string; diff --git a/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx index 637ca2380f..39d95d251f 100644 --- a/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx +++ b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx @@ -30,8 +30,9 @@ const AvailabilityScheduleView: React.FC = ({ }) => { const totalUsers = usersToAvailabilities.size; const [selectedTimeslot, setSelectedTimeslot] = useState(null); + // Use displayDate if provided, otherwise fall back to event's initial date. - const initialDate = displayDate || (event.initialDateScheduled ?? new Date()); + const initialDate = event.initialDateScheduled || displayDate || new Date(); const potentialDays = getNextSevenDays(initialDate); // Handle hover - updates the sidebar with available/unavailable users and slot info diff --git a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx index 35daeb1ff7..ec0af70668 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx @@ -12,8 +12,8 @@ import { User, UserWithScheduleSettings, EventWithMembers, - isAdmin, - EventStatus + EventStatus, + isAdmin } from 'shared'; import PageLayout from '../../../components/PageLayout'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -401,10 +401,10 @@ export const EventAvailabilityPage: React.FC = () => { )} {/* Schedule button for creators - only show if event is not already scheduled */} - {(isCreator || isAdmin(currentUser.role)) && selectedSlot && event.status !== EventStatus.SCHEDULED && ( + {(isCreator || isAdmin(currentUser.role)) && selectedSlot && ( - Schedule Event + {event.status === EventStatus.SCHEDULED ? 'Reschedule Event' : 'Schedule Event'} )} @@ -440,6 +440,7 @@ export const EventAvailabilityPage: React.FC = () => { selectedDay={selectedSlot.day} startHour={selectedSlot.startHour} endHour={selectedSlot.endHour} + beingRescheduled={event.status === EventStatus.SCHEDULED} /> )} diff --git a/src/frontend/src/pages/CalendarPage/Components/ScheduleEventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/ScheduleEventModal.tsx index 9d2088dd52..a9a311b85d 100644 --- a/src/frontend/src/pages/CalendarPage/Components/ScheduleEventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/ScheduleEventModal.tsx @@ -22,6 +22,7 @@ interface ScheduleEventModalProps { selectedDay: Date; startHour: number; endHour: number; + beingRescheduled: boolean; } const ScheduleEventModal: React.FC = ({ @@ -31,7 +32,8 @@ const ScheduleEventModal: React.FC = ({ eventName, selectedDay, startHour, - endHour + endHour, + beingRescheduled }) => { const toast = useToast(); const history = useHistory(); @@ -47,7 +49,7 @@ const ScheduleEventModal: React.FC = ({ const handleConfirm = async () => { try { await scheduleEvent({ startTime, endTime }); - toast.success('Event scheduled successfully!'); + toast.success(beingRescheduled ? 'Event rescheduled successfully!' : 'Event scheduled successfully!'); onClose(); history.push(routes.CALENDAR); } catch (e) { @@ -59,12 +61,12 @@ const ScheduleEventModal: React.FC = ({ return ( - Schedule {eventName} + + {beingRescheduled ? 'Reschedule' : 'Schedule'} {eventName} + - - You are about to schedule this event for: - + You are about to {beingRescheduled ? 'reschedule' : 'schedule'} this event for: @@ -73,8 +75,11 @@ const ScheduleEventModal: React.FC = ({ {formatEventTime(startTime)} - {formatEventTime(endTime)} + - This will change the event status to SCHEDULED and notify all members. + {beingRescheduled + ? 'All members will be notified about the rescheduled time once changed.' + : 'This will change the event status to SCHEDULED and notify all members.'} @@ -82,8 +87,9 @@ const ScheduleEventModal: React.FC = ({ Cancel + - {isLoading ? 'Scheduling...' : 'Confirm Schedule'} + {beingRescheduled ? 'Confirm Reschedule' : 'Confirm Schedule'} diff --git a/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx index 643bd3bbd2..2fba854432 100644 --- a/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Alert, Box, Button, IconButton, Link, Popover, Stack, Typography, useTheme } from '@mui/material'; +import { Alert, Box, Button, IconButton, Link, Popover, Stack, Tooltip, Typography, useTheme } from '@mui/material'; import { Calendar, DayOfWeek, @@ -60,6 +60,16 @@ export const getStatusIcon = (status: string, isLarge?: boolean) => { return statusIcons.get(status); }; +const getStatusReasoning = (status: EventStatus) => { + const statusToReason: Map = new Map([ + [EventStatus.UNCONFIRMED, 'Not all required attendees have confirmed availabilities'], + [EventStatus.CONFIRMED, 'All required attendees have confirmed availabilities'], + [EventStatus.SCHEDULED, 'The event has been scheduled'], + [EventStatus.DONE, 'This event is already finished'] + ]); + return statusToReason.get(status); +}; + const stopClick: React.MouseEventHandler = (e) => { e.stopPropagation(); }; @@ -127,7 +137,7 @@ export const EventClickContent: React.FC = ({ const canEditOrDelete = event.userCreated.userId === currentUser.userId || isAdmin(currentUser.role) || isHead(currentUser.role); - const eventDate = clickedDate || event.startTime; + const eventDate = event.initialDateScheduled || clickedDate || event.startTime; const availabilityUrl = `${routes.CALENDAR}/event/${event.eventId}?date=${eventDate.toISOString()}`; @@ -206,66 +216,82 @@ export const EventClickContent: React.FC = ({ - { - stopClick(e); - handleExport(event); - }} - sx={{ - color: theme.palette.grey[500], - '&:hover': { color: theme.palette.common.white, bgcolor: 'transparent' } - }} - > - - - - {!disable && canEditOrDelete && ( + { stopClick(e); - onEdit(event); + handleExport(event); }} sx={{ color: theme.palette.grey[500], '&:hover': { color: theme.palette.common.white, bgcolor: 'transparent' } }} > - + + + + {!disable && canEditOrDelete && ( + + { + stopClick(e); + onEdit(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { color: theme.palette.common.white, bgcolor: 'transparent' } + }} + > + + + )} {!disable && canEditOrDelete && ( - { - stopClick(e); - onDelete(event); - }} - sx={{ - color: theme.palette.grey[500], - '&:hover': { color: '#ef5350', bgcolor: 'transparent' } - }} - > - - + + { + stopClick(e); + onDelete(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { color: '#ef5350', bgcolor: 'transparent' } + }} + > + + + )} - {dayOfWeek && } + {dayOfWeek && ( + + + + )} {dayOfWeek && !event.allDay && ( {formatEventTime(event.startTime)} – {formatEventTime(event.endTime)} )} {dayOfWeek && event.allDay && All day} - {!dayOfWeek && } + {!dayOfWeek && ( + + + + )} {hasValue(locationText) && ( <> - + + + {locationText} )} @@ -276,7 +302,9 @@ export const EventClickContent: React.FC = ({ {/* Required */} {hasValue(requiredText) && ( - + + + Required: {requiredText} @@ -286,7 +314,9 @@ export const EventClickContent: React.FC = ({ {/* Optional */} {hasValue(optionalText) && ( - + + + Optional: {optionalText} @@ -315,7 +345,9 @@ export const EventClickContent: React.FC = ({ {specificEventType?.requiresConfirmation && showAvailabilityButton && ( - + + + + + )} + {addApprovalButtons && canApprove && ( { ); }; +// similar to isSameDay, but created to counter a one day forward off error +const isSameDayUTC = (date1: Date, date2: Date): boolean => { + date1 = new Date(date1); + date2 = new Date(date2); + return ( + date1.getUTCFullYear() === date2.getUTCFullYear() && + date1.getUTCMonth() === date2.getUTCMonth() && + date1.getUTCDate() === date2.getUTCDate() + ); +}; + const isWithinSameWeek = (date1: Date, date2: Date): boolean => { // Function to find the Saturday of the week for a given date @@ -158,6 +169,7 @@ export { getSaturday, getMostRecentAvailabilities, isSameDay, + isSameDayUTC, getDayOfWeek, getNextSevenDays, getUniqueAvailabilities