diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index 53377b9650..6e1edac0a1 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -596,4 +596,18 @@ export default class CalendarController { next(error); } } + + static async getAllEventsPaginated(req: Request, res: Response, next: NextFunction) { + try { + const { cursor, pageSize } = req.body; + const paginatedEvents = await CalendarService.getAllEventsPaginated( + req.organization, + cursor ? new Date(cursor) : undefined, + pageSize ? parseInt(pageSize) : undefined + ); + res.status(200).json(paginatedEvents); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 6f7e60ce12..8eb5e6a94e 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -297,5 +297,6 @@ calendarRouter.post( ); calendarRouter.get('/calendars', CalendarController.getAllCalendars); +calendarRouter.post('/events-paginated', CalendarController.getAllEventsPaginated); export default calendarRouter; diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index 7aa1033af7..0d7ad9b64f 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -15,7 +15,8 @@ import { Machinery, ScheduleSlot, notGuest, - isSameDay + isSameDay, + EventInstance } from 'shared'; import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; @@ -2749,4 +2750,56 @@ export default class CalendarService { }); return eventTypes.map(eventTypeTransformer); } + + /** + * Gets all the events paginated, ordered by start time and grouped by date + * @param organization the org the user is currently in + * @param cursor the start time of the last event on the prev page + * @param pageSize the number of events to return per page + * @returns + */ + static async getAllEventsPaginated( + organization: Organization, + cursor?: Date, + pageSize: number = 25 + ): Promise<{ instances: EventInstance[]; nextCursor: Date | null }> { + const now = new Date(); + + const slots = await prisma.schedule_Slot.findMany({ + where: { + startTime: { + lt: cursor ?? now + }, + event: { + dateDeleted: null, + status: Event_Status.SCHEDULED, + eventType: { + organizationId: organization.organizationId + } + } + }, + include: { + event: getEventQueryArgs(organization.organizationId) + }, + orderBy: { startTime: 'desc' }, + take: pageSize + }); + + const nextCursor = slots.length === pageSize ? slots[slots.length - 1].startTime : null; + + const instances: EventInstance[] = slots.map((slot) => { + const { scheduledTimes, ...eventWithoutSlots } = eventTransformer(slot.event); + return { + ...eventWithoutSlots, + scheduleSlotId: slot.scheduleSlotId, + startTime: slot.startTime, + endTime: slot.endTime, + allDay: slot.allDay, + recurring: slot.event.scheduledTimes.length > 1, + totalScheduledSlots: slot.event.scheduledTimes.length + }; + }); + + return { instances, nextCursor }; + } } diff --git a/src/frontend/src/apis/calendar.api.ts b/src/frontend/src/apis/calendar.api.ts index 338a329db7..1064bf8eb2 100644 --- a/src/frontend/src/apis/calendar.api.ts +++ b/src/frontend/src/apis/calendar.api.ts @@ -11,7 +11,8 @@ import { EventTypeCreateArgs, Calendar, FilterArgs, - ScheduleSlot + ScheduleSlot, + EventInstance } from 'shared'; import { eventTransformer, eventWithMembersTransformer } from './transformers/calendar.transformer'; import { EditEventArgs, EditScheduleSlotArgs, EventCreateArgs } from '../hooks/calendar.hooks'; @@ -266,3 +267,19 @@ export const scheduleEvent = async (eventId: string, payload: { startTime: Date; transformResponse: (data) => eventTransformer(JSON.parse(data)) }); }; + +export const getAllEventsPaginated = (cursor?: Date, pageSize?: number) => { + return axios.post<{ instances: EventInstance[]; nextCursor: Date | null }>( + apiUrls.calendarEventsPaginated(), + { cursor, pageSize }, + { + transformResponse: (data) => { + const parsed = JSON.parse(data); + return { + instances: parsed.instances, + nextCursor: parsed.nextCursor ? new Date(parsed.nextCursor) : null + }; + } + } + ); +}; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 0094ddba8c..3c7a928545 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -34,6 +34,7 @@ import { useCurrentOrganization } from '../hooks/organizations.hooks'; import Statistics from '../pages/StatisticsPage/Statistics'; import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective'; import Calendar from '../pages/CalendarPage/Calendar'; +import GuestEventPage from '../pages/GuestEventPage/GuestEventPage'; interface AppAuthenticatedProps { userId: string; @@ -130,6 +131,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) + diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 0982bd658f..98531729c5 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -11,7 +11,8 @@ import { FilterArgs, ScheduleSlotCreateArgs, EventWithMembers, - ScheduleSlot + ScheduleSlot, + EventInstance } from 'shared'; import { getAllShops, @@ -48,7 +49,8 @@ import { getSingleEventWithMembers, previewScheduleSlotRecurringEdits, postDeleteScheduleSlot, - scheduleEvent + scheduleEvent, + getAllEventsPaginated } from '../apis/calendar.api'; import { useCurrentUser } from './users.hooks'; import { PDFDocument } from 'pdf-lib'; @@ -664,3 +666,16 @@ export const combinePdfsAndDownload = async (blobData: Blob[], filename: string) const pdfBlob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); saveAs(pdfBlob, filename); }; + +/** + * Custom hook to get all events in a paginated manner, sorted by scheduled date ascending. + */ +export const useAllEventsPaginated = (cursor?: Date, pageSize?: number) => { + return useQuery<{ instances: EventInstance[]; nextCursor: Date | null }, Error>( + ['events', 'paginated', cursor, pageSize], + async () => { + const { data } = await getAllEventsPaginated(cursor, pageSize); + return data; + } + ); +}; diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 93e731e345..4d4f2dd0c7 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -9,7 +9,6 @@ import styles from '../../stylesheets/layouts/sidebar/sidebar.module.css'; import { Typography, Box, IconButton, Divider } from '@mui/material'; import HomeIcon from '@mui/icons-material/Home'; import AlignHorizontalLeftIcon from '@mui/icons-material/AlignHorizontalLeft'; -import RateReviewIcon from '@mui/icons-material/RateReview'; import DashboardIcon from '@mui/icons-material/Dashboard'; // To be uncommented after guest sponsors page is developed // import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; @@ -37,6 +36,7 @@ import QueryStatsIcon from '@mui/icons-material/QueryStats'; import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import { useState } from 'react'; +import { CalendarIcon } from '@mui/x-date-pickers'; interface SidebarProps { drawerOpen: boolean; @@ -101,9 +101,9 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid route: routes.CHANGE_REQUESTS }, { - name: 'Design Review', - icon: , - route: routes.CALENDAR + name: 'Events', + icon: , + route: routes.EVENTS } ] }, diff --git a/src/frontend/src/pages/GuestEventPage/GuestEventCard.tsx b/src/frontend/src/pages/GuestEventPage/GuestEventCard.tsx new file mode 100644 index 0000000000..291194aff1 --- /dev/null +++ b/src/frontend/src/pages/GuestEventPage/GuestEventCard.tsx @@ -0,0 +1,80 @@ +import { alpha, Box, Card, CardContent, Chip, Stack, Typography, useTheme } from '@mui/material'; +import { EventInstance, formatEventDate, formatEventTime } from 'shared'; +import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined'; + +interface GuestEventCardProps { + event: EventInstance; +} + +const GuestEventCard: React.FC = ({ event }) => { + const theme = useTheme(); + + const displayDate = new Date(event.startTime); + const formattedDate = formatEventDate(displayDate); + const formattedTime = formatEventTime(displayDate); + + const wbsLabels = event.workPackages.map( + (wp) => + `${wp.wbsElement.carNumber}.${wp.wbsElement.projectNumber}.${wp.wbsElement.workPackageNumber} - ${wp.wbsElement.name}` + ); + + const title = wbsLabels.length > 0 ? wbsLabels[0] : event.title; + const extraWbs = wbsLabels.slice(1); + + return ( + + + + + + {title} + + {extraWbs.map((label) => ( + + {label} + + ))} + + + + + + + {formattedDate} @ {formattedTime} + + + + + {event.teams.length > 0 && ( + + {event.teams.map((team) => ( + + ))} + + )} + + {event.description && ( + + {event.description} + + )} + + + + ); +}; + +export default GuestEventCard; diff --git a/src/frontend/src/pages/GuestEventPage/GuestEventPage.tsx b/src/frontend/src/pages/GuestEventPage/GuestEventPage.tsx new file mode 100644 index 0000000000..8cb7a23096 --- /dev/null +++ b/src/frontend/src/pages/GuestEventPage/GuestEventPage.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { Box, Button } from '@mui/material'; +import { Collapse, IconButton, Stack, Typography, useTheme } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import GuestEventCard from './GuestEventCard'; +import { EventInstance, formatEventDate } from 'shared'; +import { useAllEventsPaginated } from '../../hooks/calendar.hooks'; + +const groupInstancesByDate = (instances: EventInstance[]): [string, EventInstance[]][] => { + const groups = new Map(); + for (const instance of instances) { + const date = new Date(instance.startTime); + const key = formatEventDate(date); + if (!groups.has(key)) groups.set(key, { date, instances: [] }); + groups.get(key)!.instances.push(instance); + } + return Array.from(groups.entries()) + .sort(([, a], [, b]) => b.date.getTime() - a.date.getTime()) + .map(([key, { instances }]) => [key, instances]); +}; + +interface DateGroupProps { + date: string; + instances: EventInstance[]; +} + +const DateGroup: React.FC = ({ date, instances }) => { + const theme = useTheme(); + const [open, setOpen] = useState(true); + + return ( + + setOpen((prev) => !prev)} + > + + {date} + + {open ? : } + + + + {instances.map((instance) => ( + + ))} + + + + ); +}; + +const GuestEventPage: React.FC = () => { + const [cursor, setCursor] = useState(undefined); + const [allInstances, setAllInstances] = useState([]); + const { data, isLoading, isError, error } = useAllEventsPaginated(cursor); + + useEffect(() => { + if (data?.instances) { + setAllInstances((prev) => { + const existingKeys = new Set(prev.map((i) => `${i.eventId}-${i.scheduleSlotId}`)); + const newInstances = data.instances.filter((i) => !existingKeys.has(`${i.eventId}-${i.scheduleSlotId}`)); + return newInstances.length > 0 ? [...prev, ...newInstances] : prev; + }); + } + }, [data]); + + if (isLoading && allInstances.length === 0) return ; + if (isError) return ; + + const groups = groupInstancesByDate(allInstances); + + return ( + + {groups.map(([date, instances]) => ( + + ))} + {data?.nextCursor && ( + + )} + + ); +}; + +export default GuestEventPage; diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index 5194281381..fece77773b 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -66,6 +66,7 @@ const PROJECT_TEMPLATE_EDIT = PROJECT_TEMPLATES + '/edit'; /**************** Design Review Calendar ****************/ const CALENDAR = `/calendar`; +const EVENTS = '/events'; /**************** Organizations ****************/ const ORGANIZATIONS = `/organizations`; @@ -135,6 +136,7 @@ export const routes = { PROJECT_TEMPLATE_EDIT, CALENDAR, + EVENTS, ORGANIZATIONS, diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 07493fd150..281f2a9d19 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -462,6 +462,7 @@ const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; const calendar = () => `${API_URL}/calendar`; const calendarShops = () => `${calendar()}/shops`; const calendarEvents = () => `${calendar()}/events`; +const calendarEventsPaginated = () => `${calendar()}/events-paginated`; const calendarEventTypes = () => `${calendar()}/event-types`; const calendarCreateShop = () => `${calendar()}/shop/create`; const calendarFilterEvents = () => `${calendar()}/events/filter`; @@ -846,6 +847,7 @@ export const apiUrls = { calendarGetSingleEventWithMembers, calendarGetConflictingEvent, calendarEvents, + calendarEventsPaginated, calendarEventTypes, calendarDeleteEvent, calendarEventSetStatus,