diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index f87285309f..22fd4dc0b2 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -498,6 +498,17 @@ export default class CalendarController { } } + static async getSingleEventWithMembers(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params; + + const event = await CalendarService.getSingleEventWithMembers(req.currentUser, eventId, req.organization); + res.status(200).json(event); + } catch (error: unknown) { + next(error); + } + } + static async getConflictingEvent(req: Request, res: Response, next: NextFunction) { try { const { eventId } = req.params; diff --git a/src/backend/src/prisma-query-args/event.query-args.ts b/src/backend/src/prisma-query-args/event.query-args.ts index bfaeac073a..f90cbc6d1f 100644 --- a/src/backend/src/prisma-query-args/event.query-args.ts +++ b/src/backend/src/prisma-query-args/event.query-args.ts @@ -3,6 +3,8 @@ import { getUserQueryArgs, getUserWithSettingsQueryArgs } from './user.query-arg export type EventQueryArgs = ReturnType; +export type EventWithMembersQueryArgs = ReturnType; + export const getEventQueryArgs = (organizationId: string) => Prisma.validator()({ include: { @@ -60,3 +62,67 @@ export const getEventQueryArgs = (organizationId: string) => documents: true } }); + +export const getEventWithMembersQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + userCreated: getUserWithSettingsQueryArgs(organizationId), + requiredMembers: getUserQueryArgs(organizationId), + optionalMembers: getUserQueryArgs(organizationId), + confirmedMembers: getUserWithSettingsQueryArgs(organizationId), + deniedMembers: getUserQueryArgs(organizationId), + teams: { + include: { + members: getUserQueryArgs(organizationId), + leads: getUserQueryArgs(organizationId), + head: getUserQueryArgs(organizationId) + } + }, + teamType: { + include: { + teams: { + include: { + members: getUserQueryArgs(organizationId), + leads: getUserQueryArgs(organizationId), + head: getUserQueryArgs(organizationId) + } + } + } + }, + shops: { + select: { + name: true, + shopId: true + } + }, + machinery: { + select: { + name: true, + machineryId: true + } + }, + workPackages: { + select: { + wbsElement: { + select: { + name: true, + carNumber: true, + projectNumber: true, + workPackageNumber: true + } + }, + project: { + include: { + wbsElement: true, + teams: true + } + }, + workPackageId: true + } + }, + approvalRequiredBy: getUserQueryArgs(organizationId), + scheduledTimes: true, + notificationSlackThreads: true, + documents: true + } + }); diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 73d46cf744..1e52e3f3a0 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -199,6 +199,8 @@ calendarRouter.get('/event/:eventId/conflict', CalendarController.getConflicting calendarRouter.get('/event/:eventId', CalendarController.getSingleEvent); +calendarRouter.get('/event-members/:eventId', CalendarController.getSingleEventWithMembers); + calendarRouter.get('/events', CalendarController.getAllEvents); calendarRouter.get('/event-types', CalendarController.getAllEventTypes); diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index 90b85fd873..a2cf4731a7 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -1,4 +1,9 @@ -import { calendarTransformer, eventTransformer, machineryTransformer } from '../transformers/calendar.transformer'; +import { + calendarTransformer, + eventTransformer, + eventWithMembersTransformer, + machineryTransformer +} from '../transformers/calendar.transformer'; import { getMachineryQueryArgs } from '../prisma-query-args/machinery.query-args'; import { Conflict_Status, Event_Status, Organization } from '@prisma/client'; import { @@ -39,7 +44,7 @@ import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-arg import { shopTransformer } from '../transformers/calendar.transformer'; import { getShopQueryArgs } from '../prisma-query-args/shop.query-args'; import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args'; -import { getEventQueryArgs } from '../prisma-query-args/event.query-args'; +import { getEventQueryArgs, getEventWithMembersQueryArgs } from '../prisma-query-args/event.query-args'; import { buildScheduledTimesOverlap, checkEventConflicts, @@ -1144,13 +1149,13 @@ export default class CalendarService { ): Promise { const event = await prisma.event.findUnique({ where: { eventId }, - ...getEventQueryArgs(organization.organizationId) + ...getEventWithMembersQueryArgs(organization.organizationId) }); if (!event) throw new NotFoundException('Event', eventId); if (event.dateDeleted) throw new DeletedException('Event', eventId); - if (!isUserOnEvent(submitter, eventTransformer(event))) + if (!isUserOnEvent(submitter, eventWithMembersTransformer(event))) throw new HttpException(400, 'Current user is not in the list of this events members'); let userSettings = await prisma.schedule_Settings.findUnique({ @@ -2374,6 +2379,27 @@ export default class CalendarService { return eventTransformer(event); } + /** + * Retrieves a single event + * + * @param submitter the user who is trying to retrieve the event + * @param eventId the id of the event to retrieve + * @param organizationId the organization that the user is currently in + * @returns the event + */ + static async getSingleEventWithMembers(_submitter: User, eventId: string, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventWithMembersQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + return eventTransformer(event); + } + /** * Retrieves a potential conflicting event * If no conflict exists, returns the original event diff --git a/src/backend/src/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts index 06b5f5633d..681bc5d593 100644 --- a/src/backend/src/transformers/calendar.transformer.ts +++ b/src/backend/src/transformers/calendar.transformer.ts @@ -16,13 +16,14 @@ import { EventPreview, DayOfWeek, ConflictStatus, - Document + Document, + EventWithMembers } from 'shared'; import { MachineryQueryArgs, ShopMachineryQueryArgs } from '../prisma-query-args/machinery.query-args'; import { userTransformer, userWithScheduleSettingsTransformer } from './user.transformer'; import { EventTypeQueryArgs } from '../prisma-query-args/event-type.query-args'; import { CalendarQueryArgs } from '../prisma-query-args/calendar.query-args'; -import { EventQueryArgs } from '../prisma-query-args/event.query-args'; +import { EventQueryArgs, EventWithMembersQueryArgs } from '../prisma-query-args/event.query-args'; import { ShopQueryArgs } from '../prisma-query-args/shop.query-args'; export const documentTransformer = (document: Prisma.DocumentGetPayload): Document => { @@ -140,6 +141,49 @@ export const eventTransformer = (event: Prisma.EventGetPayload): }; }; +export const eventWithMembersTransformer = (event: Prisma.EventGetPayload): EventWithMembers => { + return { + eventId: event.eventId, + title: event.title, + userCreated: userWithScheduleSettingsTransformer(event.userCreated), + dateCreated: event.dateCreated, + eventTypeId: event.eventTypeId, + requiredMembers: event.requiredMembers.map(userTransformer), + optionalMembers: event.optionalMembers.map(userTransformer), + confirmedMembers: event.confirmedMembers.map(userWithScheduleSettingsTransformer), + deniedMembers: event.deniedMembers.map(userTransformer), + teams: event.teams.map((team) => ({ + ...team, + members: team.members.map(userTransformer), + leads: team.leads.map(userTransformer), + head: userTransformer(team.head) + })), + teamType: event.teamType + ? { + teamTypeId: event.teamType.teamTypeId, + name: event.teamType.name, + teams: event.teamType.teams.map((team) => ({ + members: team.members.map(userTransformer), + leads: team.leads.map(userTransformer), + head: userTransformer(team.head) + })) + } + : undefined, + shops: event.shops, + machinery: event.machinery, + workPackages: event.workPackages, + documents: event.documents.filter((document) => !document.dateDeleted).map(documentTransformer), + scheduledTimes: event.scheduledTimes.map(scheduleTimesTransformer), + approved: conflictStatusTransformer(event.approved), + approvalRequiredFrom: event.approvalRequiredBy ?? undefined, + location: event.location ?? undefined, + zoomLink: event.zoomLink ?? undefined, + questionDocumentLink: event.questionDocumentLink ?? undefined, + description: event.description ?? undefined, + status: eventStatusTransformer(event.status) + }; +}; + export const eventPreviewTransformer = (event: Prisma.EventGetPayload, wbsName: string): EventPreview => { // Get the earliest scheduled date from scheduledTimes const dateScheduled = event.scheduledTimes.length > 0 ? event.scheduledTimes[0].initialDateScheduled : new Date(); diff --git a/src/backend/src/utils/calendar.utils.ts b/src/backend/src/utils/calendar.utils.ts index 322b250ec2..fa07f5c4f1 100644 --- a/src/backend/src/utils/calendar.utils.ts +++ b/src/backend/src/utils/calendar.utils.ts @@ -1,5 +1,13 @@ import { Prisma, Event_Type, Organization } from '@prisma/client'; -import { User, ScheduleSlotCreateArgs, Event, EventDocumentCreateArgs, Document, ConflictStatus } from 'shared'; +import { + User, + ScheduleSlotCreateArgs, + EventDocumentCreateArgs, + Document, + ConflictStatus, + Event, + EventWithMembers +} from 'shared'; import { InvalidEventTypeConfigurationException } from './errors.utils'; import prisma from '../prisma/prisma'; import { getEventQueryArgs } from '../prisma-query-args/event.query-args'; @@ -15,10 +23,47 @@ export function buildScheduledTimesOverlap(start?: Date, end?: Date): Prisma.Sch return { some: { AND } }; } -export const isUserOnEvent = (user: User, event: Event): boolean => { - const requiredMembers = event.requiredMembers.map((user) => user.userId); - const optionalMembers = event.optionalMembers.map((user) => user.userId); - return requiredMembers.includes(user.userId) || optionalMembers.includes(user.userId); +export const isUserOnEvent = (user: User, event: EventWithMembers): boolean => { + // Check if user is directly a required or optional member + const isDirectMember = + event.requiredMembers.some((member) => member.userId === user.userId) || + event.optionalMembers.some((member) => member.userId === user.userId); + + if (isDirectMember) { + return true; + } + + // Check if user is on any of the event's teams (as member, lead, or head) + const isOnEventTeam = event.teams.some( + (team) => + team.members.some((member) => member.userId === user.userId) || + team.leads.some((lead) => lead.userId === user.userId) || + team.head.userId === user.userId + ); + + if (isOnEventTeam) { + return true; + } + + // Check if user is on any team that belongs to the event's team type + if (event.teamType) { + const isOnTeamType = event.teamType.teams.some( + (team) => + team.members.some((member) => member.userId === user.userId) || + team.leads.some((lead) => lead.userId === user.userId) || + team.head.userId === user.userId + ); + + if (isOnTeamType) { + return true; + } + } + + if (event.userCreated.userId === user.userId) { + return true; + } + + return false; }; /** diff --git a/src/frontend/src/apis/calendar.api.ts b/src/frontend/src/apis/calendar.api.ts index da94f0767b..202a2fbeac 100644 --- a/src/frontend/src/apis/calendar.api.ts +++ b/src/frontend/src/apis/calendar.api.ts @@ -126,6 +126,12 @@ export const getSingleEvent = async (id: string) => { }); }; +export const getSingleEventWithMembers = async (id: string) => { + return axios.get(apiUrls.calendarGetSingleEventWithMembers(id), { + transformResponse: (data) => eventTransformer(JSON.parse(data)) + }); +}; + export const getConflictingEvent = async (id: string) => { return axios.get(apiUrls.calendarGetConflictingEvent(id), { transformResponse: (data) => eventTransformer(JSON.parse(data)) diff --git a/src/frontend/src/apis/transformers/calendar.transformer.ts b/src/frontend/src/apis/transformers/calendar.transformer.ts index 6f75053806..978c5ad74c 100644 --- a/src/frontend/src/apis/transformers/calendar.transformer.ts +++ b/src/frontend/src/apis/transformers/calendar.transformer.ts @@ -1,4 +1,4 @@ -import { Shop, Event, EventPreview } from 'shared'; +import { Shop, Event, EventPreview, EventWithMembers } from 'shared'; import { userTransformer } from './users.transformers'; export const shopTransformer = (shop: Shop): Shop => { @@ -27,6 +27,20 @@ export const eventTransformer = (event: Event): Event => { }; }; +export const eventWithMembersTransformer = (event: EventWithMembers): EventWithMembers => { + return { + ...event, + dateCreated: new Date(event.dateCreated), + scheduledTimes: event.scheduledTimes.map((slot: any) => ({ + ...slot, + startTime: slot.startTime ? new Date(slot.startTime) : undefined, + endTime: slot.endTime ? new Date(slot.endTime) : undefined, + initialDateScheduled: new Date(slot.initialDateScheduled), + endDate: new Date(slot.endDate) + })) + }; +}; + export const eventPreviewTransformer = (event: EventPreview): EventPreview => { return { eventId: event.eventId, diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 300e198b02..d0489867a8 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -33,7 +33,7 @@ import { useHomePageContext } from './HomePageContext'; import { useCurrentOrganization } from '../hooks/organizations.hooks'; import Statistics from '../pages/StatisticsPage/Statistics'; import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective'; -import NewCalendar from '../pages/NewCalendarPage/NewCalendar'; +import NewCalendar from '../pages/CalendarPage/NewCalendar'; interface AppAuthenticatedProps { userId: string; diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 6b1ae1ed9f..605e7bda87 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -9,7 +9,8 @@ import { EventStatus, EventType, FilterArgs, - ScheduleSlotCreateArgs + ScheduleSlotCreateArgs, + EventWithMembers } from 'shared'; import { getAllShops, @@ -41,7 +42,8 @@ import { postCreateEvent, uploadSingleDocument, downloadDocumentPdf, - postEditEvent + postEditEvent, + getSingleEventWithMembers } from '../apis/calendar.api'; import { useCurrentUser } from './users.hooks'; import { PDFDocument } from 'pdf-lib'; @@ -311,6 +313,9 @@ export const useMarkUserConfirmed = (id: string) => { onSuccess: () => { queryClient.invalidateQueries(EVENT_KEY); queryClient.invalidateQueries(['users', user.userId, 'schedule-settings']); + queryClient.invalidateQueries(['users', user.userId, 'schedule-settings']); + queryClient.invalidateQueries(['users', 'many-with-schedule-settings']); + queryClient.invalidateQueries(['users']); } } ); @@ -342,6 +347,17 @@ export const useSingleEvent = (id?: string) => { ); }; +export const useSingleEventWithMembers = (id?: string) => { + return useQuery( + ['events', id, 'with-members'], + async () => { + const { data } = await getSingleEventWithMembers(id!); + return data; + }, + { enabled: !!id } + ); +}; + export const useConflictingEvents = (ids: string[]) => { return useQuery(['events', 'conflicting', ids], async () => { const results = await Promise.all( diff --git a/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx new file mode 100644 index 0000000000..5829cb9ab6 --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx @@ -0,0 +1,134 @@ +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; +import { Availability, Event, getDayOfWeek, getNextSevenDays, User } from 'shared'; +import React, { useState } from 'react'; +import { enumToArray, getBackgroundColor, NUMBER_OF_TIME_SLOTS, REVIEW_TIMES } from '../../utils/design-review.utils'; +import { datePipe } from '../../utils/pipes'; +import EventTimeSlot from './Components/EventTimeSlot'; + +interface AvailabilityScheduleViewProps { + availableUsers: Map; + unavailableUsers: Map; + usersToAvailabilities: Map; + setCurrentAvailableUsers: (val: User[]) => void; + setCurrentUnavailableUsers: (val: User[]) => void; + event: Event; + displayDate?: Date; +} + +const AvailabilityScheduleView: React.FC = ({ + availableUsers, + unavailableUsers, + usersToAvailabilities, + setCurrentAvailableUsers, + setCurrentUnavailableUsers, + event, + displayDate +}) => { + 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.scheduledTimes[0]?.initialDateScheduled || new Date(); + const potentialDays = getNextSevenDays(initialDate); + + const handleTimeslotClick = (index: number, _day: Date) => { + if (selectedTimeslot === index) { + setSelectedTimeslot(null); + setCurrentAvailableUsers([]); + setCurrentUnavailableUsers([]); + } else { + setSelectedTimeslot(index); + setCurrentAvailableUsers(availableUsers.get(index) || []); + setCurrentUnavailableUsers(unavailableUsers.get(index) || []); + } + }; + + // Populates the availableUsers map + for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { + availableUsers.set(time, []); + } + usersToAvailabilities.forEach((availabilities, user) => { + let i = 0; + availabilities.forEach((availability) => { + availability.availability.forEach((time) => { + const usersAtTime = availableUsers.get(enumToArray(REVIEW_TIMES).length * i + time) || []; + usersAtTime.push(user); + availableUsers.set(enumToArray(REVIEW_TIMES).length * i + time, usersAtTime); + }); + i++; + }); + }); + + // Populates the unavailableUsers map + const allUsers = [...usersToAvailabilities.keys()]; + for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { + const currentUsers = availableUsers.get(time) || []; + const currentUnavailableUsers = allUsers.filter((user) => !currentUsers.includes(user)); + unavailableUsers.set(time, currentUnavailableUsers); + } + + const stickyLeft = { + position: 'sticky', + left: 0, + zIndex: 2, + bgcolor: 'background.paper' + }; + + return ( + + + + + + {potentialDays.map((day) => ( + + + {getDayOfWeek(day) + ' ' + datePipe(day)} + + + ))} + + + + {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( + + + + {time} + + + {potentialDays.map((day, dayIndex) => { + const index = dayIndex * enumToArray(REVIEW_TIMES).length + timeIndex; + return ( + + handleTimeslotClick(index, day)} + /> + + ); + })} + + ))} + +
+
+ ); +}; + +export default AvailabilityScheduleView; diff --git a/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx similarity index 97% rename from src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx rename to src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx index d8320a5938..171defcb9c 100644 --- a/src/frontend/src/pages/NewCalendarPage/CalendarDayCard.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx @@ -21,6 +21,8 @@ import GroupsIcon from '@mui/icons-material/Groups'; import { EventClickPopup } from './EventClickPopup'; import EventPartialInfoView from './EventPartialInfoView'; import { getConvertedEnd, getConvertedStart } from '../../utils/datetime.utils'; +import { EventRoutePayload } from './Components/EventModal'; +import { EditEventArgs } from '../../hooks/calendar.hooks'; export const getTeamTypeIcon = (teamTypeName: string, isLarge?: boolean) => { const teamIcons: Map = new Map([ @@ -50,6 +52,12 @@ interface CalendarDayCardProps { eventTypes?: EventType[]; calendars?: Calendar[]; dayOfWeek?: DayOfWeek; + handleEditSubmit: ( + data: EventRoutePayload, + event: Event, + editEvent: (editArgs: EditEventArgs) => Promise, + onClose: () => void + ) => Promise; } const CalendarDayCard: React.FC = ({ @@ -57,7 +65,8 @@ const CalendarDayCard: React.FC = ({ events, eventTypes = [], calendars = [], - dayOfWeek = DayOfWeek.MONDAY + dayOfWeek = DayOfWeek.MONDAY, + handleEditSubmit }) => { const [, setIsCreateModalOpen] = useState(false); const theme = useTheme(); @@ -475,6 +484,8 @@ const CalendarDayCard: React.FC = ({ eventTypes={eventTypes} calendars={calendars} dayOfWeek={dayOfWeek} + clickedDate={cardDate} + handleEditSubmit={handleEditSubmit} /> ); diff --git a/src/frontend/src/pages/NewCalendarPage/CalendarTab.tsx b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx similarity index 99% rename from src/frontend/src/pages/NewCalendarPage/CalendarTab.tsx rename to src/frontend/src/pages/CalendarPage/CalendarTab.tsx index 7e93d3e958..ffda67ec92 100644 --- a/src/frontend/src/pages/NewCalendarPage/CalendarTab.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarTab.tsx @@ -169,6 +169,7 @@ const CalendarTab: React.FC = () => { reviewEvents={reviewEvents ?? []} yourEvents={yourEvents ?? []} allCalendars={allCalendars} + handleEditSubmit={handleEditSubmit} /> ) : ( { + const isDirectMember = + event.requiredMembers?.some((member: User) => member.userId === user.userId) || + event.optionalMembers?.some((member: User) => member.userId === user.userId); + + if (isDirectMember) return true; + + const isOnEventTeam = event.teams?.some( + (team) => + team.members?.some((member: User) => member.userId === user.userId) || + team.leads?.some((lead: User) => lead.userId === user.userId) || + team.head?.userId === user.userId + ); + + if (isOnEventTeam) return true; + + if (event.teamType?.teams) { + const isOnTeamType = event.teamType.teams.some( + (team) => + team.members?.some((member: User) => member.userId === user.userId) || + team.leads?.some((lead: User) => lead.userId === user.userId) || + team.head?.userId === user.userId + ); + if (isOnTeamType) return true; + } + + if (event.userCreated?.userId === user.userId) return true; + + return false; +}; + +export const EventAvailabilityPage: React.FC = () => { + const { eventId } = useParams<{ eventId: string }>(); + const queryParams = useQueryParam(); + const dateParam = queryParams.get('date'); + const theme = useTheme(); + const history = useHistory(); + const toast = useToast(); + const currentUser = useCurrentUser(); + + const [editAvailabilityOpen, setEditAvailabilityOpen] = useState(false); + const [viewAvailabilityOpen, setViewAvailabilityOpen] = useState(false); + const [confirmedAvailabilities, setConfirmedAvailabilities] = useState>(new Map()); + const [currentAvailableUsers, setCurrentAvailableUsers] = useState([]); + const [currentUnavailableUsers, setCurrentUnavailableUsers] = useState([]); + + const { + data: event, + isError: eventError, + error: eventErrorMsg, + isLoading: eventLoading + } = useSingleEventWithMembers(eventId); + + const { + data: userScheduleSettings, + isLoading: settingsLoading, + isError: settingsIsError, + error: settingsError + } = useUserScheduleSettings(currentUser.userId); + + const allRelevantUserIds = useMemo(() => { + if (!event) return []; + + const userIds = new Set(); + + // Add required and optional members + event.requiredMembers.forEach((m) => userIds.add(m.userId)); + event.optionalMembers.forEach((m) => userIds.add(m.userId)); + + // Add creator + userIds.add(event.userCreated.userId); + + // Add team members + event.teams.forEach((team) => { + team.members.forEach((m) => userIds.add(m.userId)); + team.leads.forEach((l) => userIds.add(l.userId)); + userIds.add(team.head.userId); + }); + + // Add team type members + if (event.teamType) { + event.teamType.teams.forEach((team) => { + team.members.forEach((m) => userIds.add(m.userId)); + team.leads.forEach((l) => userIds.add(l.userId)); + userIds.add(team.head.userId); + }); + } + + return Array.from(userIds); + }, [event]); + + const { + data: relevantUsers, + isLoading: usersLoading, + isError: usersError, + error: usersErrorMsg + } = useManyUsersWithScheduleSettings(allRelevantUserIds); + + const { mutateAsync: markUserConfirmed } = useMarkUserConfirmed(eventId); + + const displayDate = useMemo(() => { + if (dateParam) { + return new Date(dateParam); + } + const raw = event?.scheduledTimes?.[0]?.initialDateScheduled; + return raw ? new Date(raw as any) : new Date(); + }, [dateParam, event?.scheduledTimes]); + + const isUserMember = useMemo(() => { + if (!event) return false; + return isUserOnEvent(currentUser, event); + }, [currentUser, event]); + + useEffect(() => { + if (userScheduleSettings && userScheduleSettings.availabilities.length > 0) { + const confirmed = getMostRecentAvailabilities(userScheduleSettings.availabilities, displayDate); + setConfirmedAvailabilities(new Map(confirmed.map((availability) => [availability.dateSet.getTime(), availability]))); + } else { + setConfirmedAvailabilities(new Map()); + } + }, [userScheduleSettings, displayDate]); + + if (eventLoading || !event) return ; + if (eventError) return ; + + if (settingsLoading) return ; + if (settingsIsError || !userScheduleSettings) return ; + + if (usersLoading || !relevantUsers) return ; + if (usersError) return ; + + const workPackageNames = event.workPackages.map((wp) => wp.wbsElement.name).join(', ') || event.title; + const editModalTitle = `Update your availability for ${workPackageNames} on the week of ${displayDate.toLocaleDateString()}`; + + const handleConfirm = async () => { + try { + await markUserConfirmed({ availability: Array.from(confirmedAvailabilities.values()) }); + toast.success('Availability Confirmed!'); + setEditAvailabilityOpen(false); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + + const handleClose = () => { + history.push(routes.NEW_CALENDAR); + }; + + const availableUsers = new Map(); + const unavailableUsers = new Map(); + const usersToAvailabilities = new Map(); + + relevantUsers.forEach((user: UserWithScheduleSettings) => { + const availability = getMostRecentAvailabilities(user.scheduleSettings?.availabilities ?? [], displayDate); + usersToAvailabilities.set(user, availability ?? []); + }); + + const getAvailabilitySummary = () => { + if (confirmedAvailabilities.size === 0) { + return 'No availability set yet. Click "Edit My Availability" to get started.'; + } + const totalSlots = Array.from(confirmedAvailabilities.values()).reduce( + (sum, avail) => sum + avail.availability.length, + 0 + ); + return `${totalSlots} time slot${totalSlots !== 1 ? 's' : ''} marked as available`; + }; + + // RENDER + return ( + + + + {/* My Availability Section */} + + + My Availability + + + {getAvailabilitySummary()} + + + setViewAvailabilityOpen(true)} + disabled={userScheduleSettings.availabilities.length === 0} + > + View My Availability + + setEditAvailabilityOpen(true)} disabled={!isUserMember}> + Edit My Availability + + + {!isUserMember && ( + + You must be a member of this event to edit availability. + + )} + + + + + + + {eventNamePipe(event)} Availability + + + {currentAvailableUsers.length > 0 + ? `${currentAvailableUsers.length}/${relevantUsers.length} available` + : 'Click a time slot to see availability'} + + + + + + Available + + + {currentAvailableUsers.length > 0 ? ( + currentAvailableUsers.map((user) => ( + + {fullNamePipe(user)} + + )) + ) : ( + + Click a time slot to see availability + + )} + + + + + + Unavailable + + + {currentUnavailableUsers.length > 0 ? ( + currentUnavailableUsers.map((user) => ( + + {fullNamePipe(user)} + + )) + ) : ( + + Click a time slot to see availability + + )} + + + + + + + + + + + + + + + Close + + + setViewAvailabilityOpen(false)} + header="My Availability" + availabilites={userScheduleSettings.availabilities} + initialDate={displayDate} + /> + + setEditAvailabilityOpen(false)} + header={editModalTitle} + confirmedAvailabilities={confirmedAvailabilities} + setConfirmedAvailabilities={setConfirmedAvailabilities} + totalAvailabilities={deeplyCopy(userScheduleSettings.availabilities, availabilityTransformer) as Availability[]} + initialDate={displayDate} + onSubmit={handleConfirm} + canChangeDateRange={false} + /> + + ); +}; diff --git a/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx similarity index 99% rename from src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx rename to src/frontend/src/pages/CalendarPage/Components/EventModal.tsx index 1a1f2fd4b0..0c7fc3f15d 100644 --- a/src/frontend/src/pages/NewCalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -271,8 +271,18 @@ const EventModal: React.FC = ({ .map((t) => ({ id: t.teamId, label: t.teamName })); setSelectedTeams(teamOptions); } + + if (initialValues?.days && initialValues.days.length > 0) { + setShowRecurringOptions(true); + } + } + }, [open, defaultFormData, initialValues, users, teams, reset]); + + useEffect(() => { + if (open && initialValues?.days && initialValues.days.length > 0) { + setShowRecurringOptions(true); } - }, [open, defaultFormData, reset, initialValues, users, teams]); + }, [open, initialValues?.days]); const selectedEventType = useMemo( () => allowedEventTypes.find((et) => et.eventTypeId === selectedEventTypeId), diff --git a/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx new file mode 100644 index 0000000000..386e447eae --- /dev/null +++ b/src/frontend/src/pages/CalendarPage/Components/EventTimeSlot.tsx @@ -0,0 +1,41 @@ +import { Box } from '@mui/system'; + +interface EventTimeSlotProps { + backgroundColor?: string; + onClick?: () => void; + selected?: boolean; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseUp?: () => void; +} + +const EventTimeSlot: React.FC = ({ + backgroundColor, + onClick, + selected = false, + onMouseDown, + onMouseEnter, + onMouseUp +}) => { + return ( + + + + ); +}; + +export default EventTimeSlot; diff --git a/src/frontend/src/pages/CalendarPage/EventAvailabilityInfo.tsx b/src/frontend/src/pages/CalendarPage/EventAvailabilityInfo.tsx deleted file mode 100644 index 3ed2a75a4b..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventAvailabilityInfo.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Event } from 'shared'; -import { Grid, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; -import ColumnHeader from '../FinancePage/FinanceComponents/ColumnHeader'; -import { fullNamePipe } from '../../utils/pipes'; - -interface EventAvailabilityInfoProps { - event: Event; -} - -export const EventAvailabilityInfo: React.FC = ({ event }) => { - return ( - - - - - - - Required - - - - - - - - {event.requiredMembers.map((member) => ( - - - {fullNamePipe(member)} - - - - {event.confirmedMembers.some((confirmedMember) => confirmedMember.userId === member.userId) - ? 'Yes' - : 'No'} - - - - ))} - -
-
-
- - - - - - Optional - - - - - - - - {event.optionalMembers.map((member) => ( - - - {fullNamePipe(member)} - - - - {event.confirmedMembers.some((confirmedMember) => confirmedMember.userId === member.userId) - ? 'Yes' - : 'No'} - - - - ))} - -
-
-
-
- ); -}; diff --git a/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx similarity index 77% rename from src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx rename to src/frontend/src/pages/CalendarPage/EventClickPopup.tsx index d7b7b561ed..49bf95cd79 100644 --- a/src/frontend/src/pages/NewCalendarPage/EventClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/EventClickPopup.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Button, IconButton, Link, Popover, Stack, Typography, useTheme } from '@mui/material'; import { Calendar, DayOfWeek, Event, EventType } from 'shared'; @@ -22,12 +22,17 @@ import DescriptionIcon from '@mui/icons-material/Description'; import HelpIcon from '@mui/icons-material/Help'; import EditIcon from '@mui/icons-material/Edit'; import PeopleIcon from '@mui/icons-material/People'; +import DeleteIcon from '@mui/icons-material/Delete'; import { getConvertedEnd, getConvertedStart } from '../../utils/datetime.utils'; import NERSuccessButton from '../../components/NERSuccessButton'; import NERFailButton from '../../components/NERFailButton'; -import { useApproveEvent, useDenyEvent } from '../../hooks/calendar.hooks'; -import { convertDayToDayShorthand } from '../../utils/calendar.utils'; +import { EditEventArgs, useApproveEvent, useDeleteEvent, useDenyEvent, useEditEvent } from '../../hooks/calendar.hooks'; +import { convertDayToDayShorthand, convertEventToFormValues } from '../../utils/calendar.utils'; +import EditEventModal from './Components/EditEventModal'; +import { useToast } from '../../hooks/toasts.hooks'; +import NERDeleteModal from '../../components/NERDeleteModal'; +import { EventRoutePayload } from './Components/EventModal'; export const getStatusIcon = (status: string, isLarge?: boolean) => { const statusIcons: Map = new Map([ @@ -51,6 +56,9 @@ interface EventClickContentProps { disable: boolean; addApprovalButtons: boolean; onClose: () => void; + onEdit: (event: Event) => void; + onDelete: (event: Event) => void; + clickedDate?: Date; } const joinPeople = (members: { firstName: string; lastName: string }[]) => @@ -68,7 +76,10 @@ const EventClickContent: React.FC = ({ dayOfWeek, disable, addApprovalButtons, - onClose + onClose, + onEdit, + onDelete, + clickedDate }) => { const { mutateAsync: approveEvent } = useApproveEvent(event.eventId); const { mutateAsync: denyEvent } = useDenyEvent(event.eventId); @@ -87,8 +98,11 @@ const EventClickContent: React.FC = ({ const showAvailabilityButton = true; - const editUrl = `${routes.SETTINGS_PREFERENCES}?eventId=${event.eventId}`; - const availabilityUrl = `${routes.CALENDAR}/${event.eventId}`; + const eventDate = + clickedDate || + (event.scheduledTimes[0]?.initialDateScheduled ? new Date(event.scheduledTimes[0].initialDateScheduled) : new Date()); + + const availabilityUrl = `${routes.NEW_CALENDAR}/event/${event.eventId}?date=${eventDate.toISOString()}`; const requiredText = event.requiredMembers.length > 0 ? joinPeople(event.requiredMembers) : ''; const optionalText = event.optionalMembers.length > 0 ? joinPeople(event.optionalMembers) : ''; @@ -116,26 +130,41 @@ const EventClickContent: React.FC = ({ }} > - {/* Edit -> availability page */} {!disable && ( - - - + + { + stopClick(e); + onEdit(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { + color: theme.palette.common.white, + bgcolor: 'transparent' + } + }} + > + + + { + stopClick(e); + onDelete(event); + }} + sx={{ + color: theme.palette.grey[500], + '&:hover': { + color: '#ef5350', + bgcolor: 'transparent' + } + }} + > + + + )} {getTeamTypeIcon(event.teamType?.name ?? '', true)} @@ -401,6 +430,13 @@ export interface EventClickPopupProps { dayOfWeek?: DayOfWeek; disable?: boolean; addApprovalButtons?: boolean; + clickedDate?: Date; + handleEditSubmit: ( + data: EventRoutePayload, + event: Event, + editEvent: (editArgs: EditEventArgs) => Promise, + onClose: () => void + ) => Promise; } export const EventClickPopup: React.FC = ({ @@ -411,8 +447,38 @@ export const EventClickPopup: React.FC = ({ calendars, dayOfWeek, disable = false, - addApprovalButtons = false + addApprovalButtons = false, + clickedDate, + handleEditSubmit }) => { + const toast = useToast(); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const { mutateAsync: deleteEvent } = useDeleteEvent(clickedEvent?.eventId ?? ''); + const { mutateAsync: editEvent } = useEditEvent(clickedEvent?.eventId ?? ''); + + const handleEdit = () => { + setShowEditModal(true); + }; + + const handleDelete = () => { + setShowDeleteModal(true); + }; + + const handleDeleteConfirm = async () => { + try { + setShowDeleteModal(false); + onClose(); + await deleteEvent(); + toast.success('Event deleted successfully!'); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + return ( = ({ disable={disable} addApprovalButtons={addApprovalButtons} onClose={onClose} + onEdit={handleEdit} + onDelete={handleDelete} + clickedDate={clickedDate} + /> + )} + {clickedEvent && showEditModal && ( + { + setShowEditModal(false); + onClose(); + }} + onSubmit={(data) => + handleEditSubmit(data, clickedEvent, editEvent, () => { + setShowEditModal(false); + onClose(); + }) + } + initialValues={convertEventToFormValues(clickedEvent)} + eventTypes={eventTypes} + defaultDate={ + clickedEvent.scheduledTimes[0]?.initialDateScheduled + ? new Date(clickedEvent.scheduledTimes[0].initialDateScheduled) + : new Date() + } + /> + )} + + {clickedEvent && showDeleteModal && ( + { + setShowDeleteModal(false); + onClose(); + }} + formId="delete-event-form" + dataType={clickedEvent.title} + onFormSubmit={handleDeleteConfirm} /> )} diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx deleted file mode 100644 index 606aa9aca2..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityScheduleView.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Grid } from '@mui/material'; -import { Availability, Event, getDayOfWeek, getNextSevenDays, User } from 'shared'; -import { - enumToArray, - REVIEW_TIMES, - HeatmapColors, - getBackgroundColor, - NUMBER_OF_TIME_SLOTS -} from '../../../utils/design-review.utils'; -import TimeSlot from '../../../components/TimeSlot'; -import React, { useState } from 'react'; -import { datePipe } from '../../../utils/pipes'; - -interface AvailabilityScheduleViewProps { - availableUsers: Map; - unavailableUsers: Map; - usersToAvailabilities: Map; - existingMeetingData: Map; - setCurrentAvailableUsers: (val: User[]) => void; - setCurrentUnavailableUsers: (val: User[]) => void; - onSelectedTimeslotChanged: (val: number | null, day: Date | null) => void; - dateRangeTitle: string; - event: Event; -} - -const AvailabilityScheduleView: React.FC = ({ - availableUsers, - unavailableUsers, - usersToAvailabilities, - existingMeetingData, - setCurrentAvailableUsers, - setCurrentUnavailableUsers, - dateRangeTitle, - onSelectedTimeslotChanged, - event -}) => { - const totalUsers = usersToAvailabilities.size; - const [selectedTimeslot, setSelectedTimeslot] = useState(null); - const initialDate = event.scheduledTimes[0]?.initialDateScheduled || new Date(); - const potentialDays = getNextSevenDays(initialDate); - - const handleTimeslotClick = (index: number, day: Date) => { - if (selectedTimeslot === index) { - setSelectedTimeslot(null); // unselect - setCurrentAvailableUsers([]); - setCurrentUnavailableUsers([]); - } else { - setSelectedTimeslot(index); // select - setCurrentAvailableUsers(availableUsers.get(index) || []); - setCurrentUnavailableUsers(unavailableUsers.get(index) || []); - } - - onSelectedTimeslotChanged(index, day); - }; - - const handleOnMouseOver = (index: number) => { - setCurrentAvailableUsers(availableUsers.get(index) || []); - setCurrentUnavailableUsers(unavailableUsers.get(index) || []); - }; - - const handleOnMouseLeave = (): void => { - if (selectedTimeslot === null) { - setCurrentAvailableUsers([]); - setCurrentUnavailableUsers([]); - } - }; - - // Populates the availableUsers map - for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { - availableUsers.set(time, []); - } - usersToAvailabilities.forEach((availabilities, user) => { - let i = 0; - availabilities.forEach((availability) => { - availability.availability.forEach((time) => { - const usersAtTime = availableUsers.get(enumToArray(REVIEW_TIMES).length * i + time) || []; - usersAtTime.push(user); - availableUsers.set(enumToArray(REVIEW_TIMES).length * i + time, usersAtTime); - }); - i++; - }); - }); - - // Populates the unavailableUsers map - const allUsers = [...usersToAvailabilities.keys()]; - for (let time = 0; time < NUMBER_OF_TIME_SLOTS; time++) { - const currentUsers = availableUsers.get(time) || []; - const currentUnavailableUsers = allUsers.filter((user) => !currentUsers.includes(user)); - unavailableUsers.set(time, currentUnavailableUsers); - } - - return ( - - - {potentialDays.map((day) => ( - - ))} - {enumToArray(REVIEW_TIMES).map((time, timeIndex) => ( - - - {potentialDays.map((day, dayIndex) => { - const index = dayIndex * enumToArray(REVIEW_TIMES).length + timeIndex; - return ( - handleTimeslotClick(index, day)} - onMouseOver={() => handleOnMouseOver(index)} - icon={existingMeetingData.get(index)} - /> - ); - })} - - ))} - - ); -}; - -export default AvailabilityScheduleView; diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityView.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityView.tsx deleted file mode 100644 index b04d835b2e..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/AvailabilityView.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Grid } from '@mui/material'; -import { Availability, Event, EventStatus, getMostRecentAvailabilities, User, UserWithScheduleSettings } from 'shared'; -import { useState } from 'react'; -import AvailabilityScheduleView from './AvailabilityScheduleView'; -import UserAvailabilites from './UserAvailabilitesView'; -import { getWeekDateRange } from '../../../utils/design-review.utils'; -import { dateRangePipe } from '../../../utils/pipes'; -import { FinalizeEventInformation } from './EventDetailPage'; -import { useManyUsersWithScheduleSettings } from '../../../hooks/users.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; - -interface AvailabilityViewProps { - event: Event; - allEvents: Event[]; - handleEdit: (data?: FinalizeEventInformation) => void; - selectedDate: Date; - setSelectDate: (date: Date) => void; - startTime: number; - endTime: number; - setStartTime: (time: number) => void; - setEndTime: (time: number) => void; - requiredUserIds: string[]; - optionalUserIds: string[]; -} - -const AvailabilityView: React.FC = ({ - event, - allEvents, - handleEdit, - selectedDate, - setSelectDate, - startTime, - endTime, - setStartTime, - setEndTime, - requiredUserIds, - optionalUserIds -}) => { - const { - data: relevantUsers, - isLoading, - isError, - error - } = useManyUsersWithScheduleSettings([...requiredUserIds, ...optionalUserIds]); - - const availableUsers = new Map(); - const unavailableUsers = new Map(); - const existingMeetingData = new Map(); - const usersToAvailabilities = new Map(); - - const [currentAvailableUsers, setCurrentAvailableUsers] = useState([]); - const [currentUnavailableUsers, setCurrentUnavailableUsers] = useState([]); - const [startDateRange, endDateRange] = getWeekDateRange(selectedDate); - - if (isLoading || !relevantUsers) return ; - if (isError) return ; - - // Get events within the current week - const currentWeekEvents = allEvents.filter((currEvent) => { - const eventDate = currEvent.scheduledTimes[0]?.initialDateScheduled; - if (!eventDate) return false; - - const drDate = new Date(eventDate).getTime(); - const startRange = startDateRange.getTime(); - const endRange = endDateRange.getTime(); - - return drDate >= startRange && drDate <= endRange; - }); - - const onSelectedTimeslotChanged = (index: number | null, day: Date | null) => { - if (index === null || day === null) return; - setStartTime(index); - setEndTime(index + 1); - setSelectDate(day); - }; - - // Find conflicting events for the selected time - const conflictingEvents = allEvents.filter((currEvent) => { - if (currEvent.eventId === event.eventId) return false; - if (currEvent.status !== EventStatus.SCHEDULED) return false; - - const eventDate = currEvent.scheduledTimes[0]?.initialDateScheduled; - if (!eventDate) return false; - - const cleanDate = new Date(eventDate.getTime() - eventDate.getTimezoneOffset() * -60000); - - // Check if event is on the selected date - if (cleanDate.toLocaleDateString() !== selectedDate.toLocaleDateString()) return false; - - // Check if any scheduled times overlap with selected time range - return currEvent.scheduledTimes.some((slot) => { - if (!slot.startTime) return false; - const slotHour = new Date(slot.startTime).getHours(); - return slotHour >= startTime + 10 && slotHour < endTime + 10; - }); - }); - - // Map existing scheduled events to time slots for visualization - currentWeekEvents.forEach((ev) => { - if (ev.status === EventStatus.SCHEDULED && ev.eventId !== event.eventId) { - ev.scheduledTimes.forEach((slot) => { - if (slot.startTime) { - const hour = new Date(slot.startTime).getHours(); - const timeIndex = hour - 10; // Convert back to 0-11 index - if (timeIndex >= 0 && timeIndex < 12) { - existingMeetingData.set(timeIndex, ev.teamType?.name || 'default'); - } - } - }); - } - }); - - // Get the initial date for availability lookup - const initialDate = event.scheduledTimes[0]?.initialDateScheduled || new Date(); - - relevantUsers.forEach((user: UserWithScheduleSettings) => { - const availability = getMostRecentAvailabilities(user.scheduleSettings?.availabilities ?? [], initialDate); - - usersToAvailabilities.set(user, availability ?? []); - }); - - return ( - - - - - - - - - ); -}; - -export default AvailabilityView; diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetailPage.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetailPage.tsx deleted file mode 100644 index 76ed2f963a..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetailPage.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { - Autocomplete, - Box, - Checkbox, - Grid, - MenuItem, - Select, - SelectChangeEvent, - TextField, - Typography, - useTheme -} from '@mui/material'; -import PageLayout from '../../../components/PageLayout'; -import AvailabilityView from './AvailabilityView'; -import { useAllMembers } from '../../../hooks/users.hooks'; -import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; -import { userToAutocompleteOption } from '../../../utils/teams.utils'; -import { useState } from 'react'; -import CheckBoxIcon from '@mui/icons-material/CheckBox'; -import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; -import { DatePicker } from '@mui/x-date-pickers'; -import { Event, meetingStartTimePipeNumbers } from 'shared'; -import { useAllEvents } from '../../../hooks/calendar.hooks'; -import { eventNamePipe } from '../../../utils/pipes'; -import { HOURS } from '../../../utils/design-review.utils'; - -export interface EventEditData { - requiredUserIds: string[]; - optionalUserIds: string[]; - selectedDate: Date; - startTime: number; - endTime: number; -} -interface EventDetailPageProps { - event: Event; -} - -export interface FinalizeEventInformation { - docTemplateLink: string; - zoomLink?: string; - location?: string; - meetingType: string[]; -} - -const EventDetailPage: React.FC = ({ event }) => { - const theme = useTheme(); - const [requiredUsers, setRequiredUsers] = useState(event.requiredMembers.map(userToAutocompleteOption)); - const [optionalUsers, setOptionalUsers] = useState(event.optionalMembers.map(userToAutocompleteOption)); - - const [firstScheduledSlot] = event.scheduledTimes; - const lastScheduledSlot = event.scheduledTimes.at(-1); - - // Convert string dates → real Date objects (defensive) - const parseDate = (dateInput: Date | string | undefined): Date => { - if (!dateInput) return new Date(); - return typeof dateInput === 'string' ? new Date(dateInput) : dateInput; - }; - - const [date, setDate] = useState( - firstScheduledSlot?.initialDateScheduled ? parseDate(firstScheduledSlot.initialDateScheduled) : new Date() - ); - - const [startTime, setStartTime] = useState( - firstScheduledSlot?.startTime ? new Date(firstScheduledSlot.startTime).getHours() - 10 : 0 - ); - - const [endTime, setEndTime] = useState( - lastScheduledSlot?.endTime - ? new Date(lastScheduledSlot.endTime).getHours() - 9 // +1 hour from start - : 1 - ); - - const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: allMembers } = useAllMembers(); - const { - data: allEvents, - isError: allEventsIsError, - error: allEventsError, - isLoading: allEventsIsLoading - } = useAllEvents(); - - if (allUsersIsError) return ; - if (allEventsIsError) return ; - if (allUsersIsLoading || !allMembers || allEventsIsLoading || !allEvents) return ; - - const users = allMembers.map(userToAutocompleteOption); - - const handleDateChange = (newDate: Date | null) => { - if (newDate) { - const updatedDateTime = new Date(); - updatedDateTime.setFullYear(newDate.getFullYear(), newDate.getMonth(), newDate.getDate()); - setDate(updatedDateTime); - } - }; - - const handleSelectingRequiredUser = (newValue: { label: string; id: string }[]) => { - const newRequiredUserIds = new Set(newValue.map((user) => user.id)); - const filteredOptionalUsers = optionalUsers.filter((user) => !newRequiredUserIds.has(user.id)); - setOptionalUsers(filteredOptionalUsers); - setRequiredUsers(newValue); - }; - - const handleEdit = async () => { - const times = []; - for (let i = startTime; i < endTime; i++) { - times.push(i % 12); - } - date.setHours(12); - /* - try { - const payload: EditDesignReviewPayload = { - dateScheduled: date, - teamTypeId: designReview.teamType.teamTypeId, - requiredMembersIds: requiredUsers.map((user) => user.id), - optionalMembersIds: optionalUsers.map((user) => user.id), - isOnline: data?.meetingType.includes('virtual') ?? false, - isInPerson: data?.meetingType.includes('inPerson') ?? false, - status: data ? DesignReviewStatus.SCHEDULED : designReview.status, - attendees: [], - meetingTimes: times, - docTemplateLink: data?.docTemplateLink ?? designReview.docTemplateLink, - zoomLink: data?.zoomLink ?? designReview.zoomLink, - location: data?.location ?? designReview.location - }; - await editDesignReview(payload); - history.push(routes.CALENDAR); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - */ - }; - - const DateField = () => { - return ; - }; - - // styling for the editable fields at the top of the page with light grey backgrounds - const EditableFieldStyle = { - fontSize: '16px', - backgroundColor: 'grey', - borderRadius: 3, - textAlign: 'left', - border: '2px solid', - width: '100%' - }; - - // styling for the non-editable fields at the top of the page with dark backgrounds - const NonEditableFieldStyle = { - padding: 1.5, - paddingTop: 1.5, - paddingBottom: 1.5, - fontSize: '1.2em', - backgroundColor: theme.palette.background.paper, - borderRadius: 3, - textAlign: 'center', - width: '100%', - border: 'none' - }; - - return ( - - - - Name - - - {eventNamePipe(event)} - - - - - - - - to - - - - - - - Required - - - - option.id === value.id} - multiple - disableCloseOnSelect - limitTags={1} - renderTags={() => null} - id="required-users" - options={users} - value={requiredUsers} - onChange={(_event, newValue) => handleSelectingRequiredUser(newValue)} - getOptionLabel={(option) => option.label} - renderOption={(props, option, { selected }) => ( -
  • - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} - /> - {option.label} -
  • - )} - renderInput={(params) => ( - - )} - /> -
    -
    - - Optional - - - - option.id === value.id} - multiple - disableCloseOnSelect - limitTags={1} - renderTags={() => null} - id="optional-users" - options={users.filter((user) => !requiredUsers.some((reqUser) => reqUser.id === user.id))} - value={optionalUsers} - onChange={(_event, newValue) => setOptionalUsers(newValue)} - getOptionLabel={(option) => option.label} - renderOption={(props, option, { selected }) => ( -
  • - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={selected} - /> - {option.label} -
  • - )} - renderInput={(params) => ( - - )} - /> -
    -
    -
    -
    -
    - user.id)} - optionalUserIds={optionalUsers.map((user) => user.id)} - startTime={startTime} - endTime={endTime} - setStartTime={setStartTime} - setEndTime={setEndTime} - /> -
    - ); -}; - -export default EventDetailPage; diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetails.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetails.tsx deleted file mode 100644 index 05b3adaa27..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/EventDetails.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * This file is part of NER's FinishLine and licensed under GNU AGPLv3. - * See the LICENSE file in the repository root folder for details. - */ -import LoadingIndicator from '../../../components/LoadingIndicator'; -import { useParams } from 'react-router-dom'; -import ErrorPage from '../../ErrorPage'; -import { useSingleEvent } from '../../../hooks/calendar.hooks'; -import EventDetailPage from './EventDetailPage'; - -const EventDetails: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const { data: event, isError, error, isLoading } = useSingleEvent(id); - - if (isError) return ; - if (!event || isLoading) return ; - - return ; -}; - -export default EventDetails; diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/FinalizeEventDetailsModal.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/FinalizeEventDetailsModal.tsx deleted file mode 100644 index 454f067898..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/FinalizeEventDetailsModal.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { Box, Grid, Link, ToggleButton, ToggleButtonGroup, Typography, Tooltip } from '@mui/material'; -import HelpIcon from '@mui/icons-material/Help'; -import React, { useState, useEffect } from 'react'; -import { Event, meetingStartTimePipeNumbers, wbsPipe } from 'shared'; -import NERFormModal from '../../../components/NERFormModal'; -import ReactHookTextField from '../../../components/ReactHookTextField'; -import { useForm } from 'react-hook-form'; -import * as yup from 'yup'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { FinalizeEventInformation } from './EventDetailPage'; -import { useCurrentUser, useUserScheduleSettings } from '../../../hooks/users.hooks'; - -interface FinalizeEventProps { - open: boolean; - setOpen: (val: boolean) => void; - event: Event; - conflictingEvents: Event[]; - startTime: number; - selectedDate: Date; - finalizeEvent: (data: FinalizeEventInformation) => void; -} - -const FinalizeEventDetailsModal = ({ - open, - setOpen, - event, - conflictingEvents, - finalizeEvent, - startTime, - selectedDate -}: FinalizeEventProps) => { - const [meetingType, setMeetingType] = useState([]); - const currentUser = useCurrentUser(); - const { data: userScheduleSettings } = useUserScheduleSettings(currentUser.userId); - - const createValidationSchema = () => - yup.object().shape({ - zoomLink: meetingType.includes('virtual') - ? yup.string().required('Meeting link is required for virtual meetings').url('Please enter a valid URL') - : yup.string().optional(), - location: yup.string().optional(), - docTemplateLink: yup.string().required('Question Doc is Required') - }); - - const [firstWorkPackage] = event.workPackages; - - const wbsNum = firstWorkPackage - ? { - carNumber: firstWorkPackage.wbsElement.carNumber, - projectNumber: firstWorkPackage.wbsElement.projectNumber, - workPackageNumber: firstWorkPackage.wbsElement.workPackageNumber - } - : { carNumber: 0, projectNumber: 0, workPackageNumber: 0 }; - - const eventName = firstWorkPackage?.wbsElement?.name - ? `${firstWorkPackage.wbsElement.carNumber}.${firstWorkPackage.wbsElement.projectNumber}.${firstWorkPackage.wbsElement.workPackageNumber} - ${firstWorkPackage.wbsElement.name}` - : event.title; - - const title = `Finalize Event for ${eventName}`; - - const eventConflicts = conflictingEvents.map( - (_event) => `${wbsPipe(wbsNum)} - ${eventName} at ${meetingStartTimePipeNumbers([startTime])}` - ); - - const defaultValues = { - docTemplateLink: event.questionDocumentLink ?? '', - zoomLink: event.zoomLink ?? userScheduleSettings?.personalZoomLink ?? '', - location: event.location ?? undefined - }; - - const { - handleSubmit, - control, - reset, - formState: { errors } - } = useForm({ - resolver: yupResolver(createValidationSchema()), - defaultValues, - mode: 'onChange' - }); - - const handleMeetingTypeChange = (_event: any, newMeetingType: string[]) => { - setMeetingType(newMeetingType); - reset(defaultValues); - }; - - const onSubmit = async (data: { docTemplateLink: string; zoomLink?: string; location?: string }) => { - finalizeEvent({ ...data, zoomLink: data.zoomLink ? data.zoomLink : undefined, meetingType }); - setOpen(false); - }; - - useEffect(() => { - if (userScheduleSettings && !event.zoomLink) { - reset({ - docTemplateLink: event.questionDocumentLink ?? '', - zoomLink: userScheduleSettings.personalZoomLink ?? '', - location: event.location ?? undefined - }); - } - if (event.zoomLink === '') { - reset({ - zoomLink: undefined - }); - } - }, [userScheduleSettings, event, reset]); - - return ( - setOpen(false)} - title={title} - reset={() => reset(defaultValues)} - handleUseFormSubmit={handleSubmit} - onFormSubmit={onSubmit} - submitText="Schedule" - formId="finalize-event-form" - > - - Meeting Time: - {`${meetingStartTimePipeNumbers([ - startTime - ])} - ${selectedDate.toDateString()}`} - - - Meeting Type: - - Virtual - In-person - - - - - Question Doc: - - Doc Template - - - - - {meetingType.includes('virtual') && ( - - - Meeting Link: - - - - - - - )} - {meetingType.includes('inPerson') && ( - - Location: - - - )} - - {eventConflicts && eventConflicts.length > 0 && ( - - - Design Review Conflicts - - - - {eventConflicts.map((conflictDesign, index) => ( - - {conflictDesign} - - ))} - - - - )} - - - ); -}; -export default FinalizeEventDetailsModal; diff --git a/src/frontend/src/pages/CalendarPage/EventDetailPage/UserAvailabilitesView.tsx b/src/frontend/src/pages/CalendarPage/EventDetailPage/UserAvailabilitesView.tsx deleted file mode 100644 index f9d86b4fcc..0000000000 --- a/src/frontend/src/pages/CalendarPage/EventDetailPage/UserAvailabilitesView.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { Typography } from '@mui/material'; -import { Box, useTheme } from '@mui/system'; -import { Availability, Event, EventStatus, User } from 'shared'; -import { HeatmapColors } from '../../../utils/design-review.utils'; -import { fullNamePipe } from '../../../utils/pipes'; -import NERFailButton from '../../../components/NERFailButton'; -import NERSuccessButton from '../../../components/NERSuccessButton'; -import { useState } from 'react'; -import { FinalizeEventInformation } from './EventDetailPage'; -import { useHistory } from 'react-router-dom'; -import { routes } from '../../../utils/routes'; -import FinalizeEventDetailsModal from './FinalizeEventDetailsModal'; - -interface UserAvailabilitiesProps { - currentAvailableUsers: User[]; - currentUnavailableUsers: User[]; - usersToAvailabilities: Map; - event: Event; - conflictingEvents: Event[]; - selectedDate: Date; - startTime: number; - handleEdit: (data?: FinalizeEventInformation) => void; -} - -const UserAvailabilites: React.FC = ({ - currentAvailableUsers, - currentUnavailableUsers, - usersToAvailabilities, - event, - conflictingEvents, - handleEdit, - selectedDate, - startTime -}) => { - const theme = useTheme(); - const history = useHistory(); - const [showFinalizeEventDetailsModal, setShowFinalizeEventDetailsModal] = useState(false); - const totalUsers = usersToAvailabilities.size; - - const handleCancel = () => { - history.push(routes.CALENDAR); - }; - - return ( - - - - 0/{totalUsers} - {Array.from({ length: 6 }, (_, i) => ( - - ))} - - {totalUsers}/{totalUsers} - - - - - - Available - - - {currentAvailableUsers.map((user) => ( - {fullNamePipe(user)} - ))} - - - - - Unavailable - - - {currentUnavailableUsers.map((user) => ( - {fullNamePipe(user)} - ))} - - - - - Cancel - handleEdit()}> - Save - - setShowFinalizeEventDetailsModal(true)} - > - Finalize - - - - - - ); -}; - -export default UserAvailabilites; diff --git a/src/frontend/src/pages/NewCalendarPage/EventPartialInfoView.tsx b/src/frontend/src/pages/CalendarPage/EventPartialInfoView.tsx similarity index 100% rename from src/frontend/src/pages/NewCalendarPage/EventPartialInfoView.tsx rename to src/frontend/src/pages/CalendarPage/EventPartialInfoView.tsx diff --git a/src/frontend/src/pages/NewCalendarPage/EventsTable.tsx b/src/frontend/src/pages/CalendarPage/EventsTable.tsx similarity index 99% rename from src/frontend/src/pages/NewCalendarPage/EventsTable.tsx rename to src/frontend/src/pages/CalendarPage/EventsTable.tsx index 70af7215c3..8405ece2dd 100644 --- a/src/frontend/src/pages/NewCalendarPage/EventsTable.tsx +++ b/src/frontend/src/pages/CalendarPage/EventsTable.tsx @@ -402,6 +402,7 @@ const EventsTable: React.FC = ({ calendars={allCalendars} disable={true} addApprovalButtons={true} + handleEditSubmit={handleEditSubmit} /> {clickedEditEvent && showEditModal && ( { + return ( + + + + + + + + ); +}; + +export default NewCalendar; diff --git a/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx b/src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx similarity index 97% rename from src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx rename to src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx index efa352290a..f7f54b9af0 100644 --- a/src/frontend/src/pages/NewCalendarPage/NewCalendarPage.tsx +++ b/src/frontend/src/pages/CalendarPage/NewCalendarPage.tsx @@ -20,7 +20,13 @@ import PageLayout from '../../components/PageLayout'; import { Calendar, ConflictStatus, DayOfWeek, Event, EventType } from 'shared'; import CalendarDayCard from './CalendarDayCard'; import { DAY_NAMES, enumToArray, calendarPaddingDays, daysInMonth } from '../../utils/design-review.utils'; -import { useConflictingEvents, useFilterEvents, useCreateEvent, useUploadManyDocuments } from '../../hooks/calendar.hooks'; +import { + useConflictingEvents, + useFilterEvents, + useCreateEvent, + useUploadManyDocuments, + EditEventArgs +} from '../../hooks/calendar.hooks'; import ErrorPage from '../ErrorPage'; import { datePipe } from '../../utils/pipes'; import LoadingIndicator from '../../components/LoadingIndicator'; @@ -47,9 +53,21 @@ interface NewCalendarPageProps { yourEvents: Event[]; reviewEvents: Event[]; allCalendars: Calendar[]; + handleEditSubmit: ( + data: EventRoutePayload, + event: Event, + editEvent: (editArgs: EditEventArgs) => Promise, + onClose: () => void + ) => Promise; } -const NewCalendarPage: React.FC = ({ allEventTypes, yourEvents, reviewEvents, allCalendars }) => { +const NewCalendarPage: React.FC = ({ + allEventTypes, + yourEvents, + reviewEvents, + allCalendars, + handleEditSubmit +}) => { const toast = useToast(); const theme = useTheme(); const { @@ -348,7 +366,7 @@ const NewCalendarPage: React.FC = ({ allEventTypes, yourEv maxWidth: 600 }} > - {deniedEvent && ( + {deniedEvent && yourConflictsDenied.length > 0 && ( } variant="filled" @@ -390,7 +408,7 @@ const NewCalendarPage: React.FC = ({ allEventTypes, yourEv
    )} - {pendingEvent && ( + {pendingEvent && yourConflicts.length > 0 && ( } variant="filled" @@ -408,7 +426,7 @@ const NewCalendarPage: React.FC = ({ allEventTypes, yourEv has been notified of this and must allow your event to take place in order to continue. )} - {reviewEvent && ( + {reviewEvent && yourReviewEvents.length > 0 && ( } variant="filled" @@ -465,7 +483,6 @@ const NewCalendarPage: React.FC = ({ allEventTypes, yourEv - {/* New Event Button (does not do anything yet) */}