From c51116867fc8965b284b81f4376c240e879a8225 Mon Sep 17 00:00:00 2001 From: Khoa Ng Date: Mon, 27 Apr 2026 15:57:10 -0700 Subject: [PATCH 1/3] SCEvents admin attendees dashboard and API extensions on dev - Reset branch onto origin/dev; keep dev Events list/registration pages - Add EventAttendeesDashboard, route /events/:id/admin/attendees - Extend SCEvents API client (registrations, registerForEvent, registerForSCEvent alias, getMyEventRegistrationState) and improve fetch error payloads - CalendarView: admin attendees link, registration state for published events - Gate Create/Edit event pages and admin nav SCEvents links on config.SCEvents.ENABLED - UserNavbar: optional Events link when SCEvents enabled Made-with: Cursor --- src/APIFunctions/SCEvents.js | 84 +++++++-- src/Components/Navbar/AdminNavbar.js | 32 +++- src/Components/Navbar/UserNavbar.js | 3 + src/Pages/Events/CalendarView.js | 63 +++++-- src/Pages/Events/CreateEventPage.js | 10 +- src/Pages/Events/EditEventPage.js | 16 +- src/Pages/Events/EventAttendeesDashboard.js | 186 ++++++++++++++++++++ src/Routes.js | 10 ++ 8 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 src/Pages/Events/EventAttendeesDashboard.js diff --git a/src/APIFunctions/SCEvents.js b/src/APIFunctions/SCEvents.js index 3e2cd82bd..f4b1a3a9d 100644 --- a/src/APIFunctions/SCEvents.js +++ b/src/APIFunctions/SCEvents.js @@ -20,7 +20,7 @@ export async function getAllSCEvents(token) { status.error = true; } } catch (err) { - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; status.error = true; } return status; @@ -44,7 +44,7 @@ export async function getEventByID(id, token) { } } catch (err) { status.error = true; - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; } return status; } @@ -64,7 +64,7 @@ export async function getEventAttendanceSummary(id, token) { } } catch (err) { status.error = true; - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; } return status; } @@ -87,7 +87,7 @@ export async function createSCEvent(token, eventBody) { } } catch (err) { status.error = true; - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; } return status; } @@ -110,14 +110,57 @@ export async function updateSCEvent(id, token, eventUpdates) { } } catch (err) { status.error = true; - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; } return status; } -export async function registerForSCEvent(eventId, token, payload) { +export async function getEventRegistrations(eventId, token, { limit = 50, offset = 0 } = {}) { + const status = new ApiResponse(); + try { + const url = new URL(`${SCEVENTS_API_URL}/events/${eventId}/registrations`); + url.searchParams.set('limit', String(limit)); + url.searchParams.set('offset', String(offset)); + + const res = await fetch(url.href, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + status.responseData = result; + if (!res.ok) { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; + } + return status; +} + +export async function getEventRegistrationByRequestId(eventId, requestId, token) { const status = new ApiResponse(); + try { + const res = await fetch(`${SCEVENTS_API_URL}/events/${eventId}/registrations/${requestId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + status.responseData = result; + if (!res.ok) { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; + } + return status; +} +export async function registerForEvent(eventId, token, payload) { + const status = new ApiResponse(); try { const res = await fetch(`${SCEVENTS_API_URL}/events/${eventId}/register`, { method: 'POST', @@ -127,18 +170,39 @@ export async function registerForSCEvent(eventId, token, payload) { }, body: JSON.stringify(payload), }); + const result = await res.json(); + status.responseData = result; + if (!res.ok) { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; + } + return status; +} - const body = await res.json(); - status.responseData = body; +export async function registerForSCEvent(eventId, token, payload) { + return registerForEvent(eventId, token, payload); +} +export async function getMyEventRegistrationState(eventId, token) { + const status = new ApiResponse(); + try { + const res = await fetch(`${SCEVENTS_API_URL}/events/${eventId}/registration/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await res.json(); + status.responseData = result; if (!res.ok) { status.error = true; } } catch (err) { status.error = true; - status.responseData = err; + status.responseData = { error: err?.message || 'Failed to connect to SCEvents API' }; } - return status; } diff --git a/src/Components/Navbar/AdminNavbar.js b/src/Components/Navbar/AdminNavbar.js index 679c54e9e..3b15bd7d5 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -1,6 +1,7 @@ import React from 'react'; import { useSCE } from '../context/SceContext'; import { membershipState } from '../../Enums'; +import config from '../../config/config.json'; export default function UserNavBar(props) { const { user, setAuthenticated } = useSCE(); @@ -43,15 +44,28 @@ export default function UserNavBar(props) { ]; const sceventsAdminNavLinks = []; - sceventsAdminNavLinks.push({ - title: 'Events', - route: '/events', - icon: ( - - - - ), - }); + if (config.SCEvents?.ENABLED) { + sceventsAdminNavLinks.push({ + title: 'Events', + route: '/events', + icon: ( + + + + ), + }); + if (user?.accessLevel >= membershipState.OFFICER) { + sceventsAdminNavLinks.push({ + title: 'Create event', + route: '/events/create', + icon: ( + + + + ), + }); + } + } const adminLinks = [ { diff --git a/src/Components/Navbar/UserNavbar.js b/src/Components/Navbar/UserNavbar.js index 301860e9d..763608282 100644 --- a/src/Components/Navbar/UserNavbar.js +++ b/src/Components/Navbar/UserNavbar.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { membershipState } from '../../Enums'; import { useSCE } from '../context/SceContext'; +import config from '../../config/config.json'; export default function UserNavbar(props) { const { user, authenticated } = useSCE(); @@ -14,12 +15,14 @@ export default function UserNavbar(props) { { title: 'About', route: '/about' }, { title: 'Projects', route: '/projects' }, { title: 'Summer Internship', route: '/s/internship', newTab: true }, + ...(config.SCEvents?.ENABLED ? [{ title: 'Events', route: '/events' }] : []), ]; const authedRoutes = [ { title: 'Printing', route: '/2DPrinting' }, { title: 'Chat', route: '/messaging' }, { title: 'LED Sign', route: '/led-sign' }, + ...(config.SCEvents?.ENABLED ? [{ title: 'Events', route: '/events' }] : []), ]; const authentication = [ diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index 69dc9fc21..742559de9 100644 --- a/src/Pages/Events/CalendarView.js +++ b/src/Pages/Events/CalendarView.js @@ -1,6 +1,6 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { getEventAttendanceSummary, joinWaitlistForSCEvent } from '../../APIFunctions/SCEvents'; +import { getEventAttendanceSummary, getMyEventRegistrationState, joinWaitlistForSCEvent } from '../../APIFunctions/SCEvents'; // ─── tiny helpers ──────────────────────────────────────────────────────────── @@ -275,8 +275,6 @@ function EventPopup({ event, onClose, isAdminView, user }) { const popupRef = useRef(null); const colors = pillColors(event, isAdminView); const badgeText = getBadgeText(event, isAdminView); - const eventId = event.id || event._id; - const authToken = user?.token || window.localStorage.getItem('jwtToken'); const userId = user?._id != null ? String(user._id) : ''; const [attendeeCount, setAttendeeCount] = useState(null); const [attendanceLoading, setAttendanceLoading] = useState(false); @@ -288,6 +286,10 @@ function EventPopup({ event, onClose, isAdminView, user }) { Array.isArray(event.admins) && userId && event.admins.includes(userId); + const [hasRegistered, setHasRegistered] = useState(false); + const [isCheckingRegistration, setIsCheckingRegistration] = useState(false); + const eventId = event?.id || event?._id; + const authToken = user?.token; const maxAttendees = Number(event.max_attendees); const hasCapacityLimit = Number.isFinite(maxAttendees) && maxAttendees > 0; const remainingSpots = @@ -311,6 +313,28 @@ function EventPopup({ event, onClose, isAdminView, user }) { return () => document.removeEventListener('keydown', onKey); }, [onClose]); + useEffect(() => { + async function fetchMyRegistrationState() { + if (!userId || canEditEvent || !user?.token || event.status !== 'published') { + setHasRegistered(false); + setIsCheckingRegistration(false); + return; + } + + setIsCheckingRegistration(true); + const eventID = event?.id || event?._id; + const response = await getMyEventRegistrationState(eventID, user.token); + if (!response.error) { + setHasRegistered(Boolean(response.responseData?.registered)); + } else { + setHasRegistered(false); + } + setIsCheckingRegistration(false); + } + + fetchMyRegistrationState(); + }, [canEditEvent, event, user?.token, userId]); + useEffect(() => { function onClickOutside(e) { if (popupRef.current && !popupRef.current.contains(e.target)) { @@ -386,12 +410,11 @@ function EventPopup({ event, onClose, isAdminView, user }) { setWaitlistMessage('Joined waitlist successfully.'); } - return ( -
+
- {hasCapacityLimit && canEditEvent && typeof attendeeCount === 'number' && ( + {event.max_attendees > 0 && (

- {attendeeCount} {attendeeCount === 1 ? 'person' : 'people'} registered + {event.max_attendees} spot{event.max_attendees !== 1 ? 's' : ''} available {event.waitlist_enabled && ( · waitlist available )} @@ -494,15 +517,23 @@ function EventPopup({ event, onClose, isAdminView, user }) { Unable to load live spots

)} - {canEditEvent && ( - - Edit event - +
+ + View attendees + + + Edit event + +
)} {!canEditEvent && event.status === 'closed' && ( diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js index a9edd305c..a0eb36cf9 100644 --- a/src/Pages/Events/CreateEventPage.js +++ b/src/Pages/Events/CreateEventPage.js @@ -1,10 +1,11 @@ /* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */ import React, { useMemo, useState } from 'react'; -import { Link, useHistory } from 'react-router-dom'; -import { useSCE } from '../../Components/context/SceContext.js'; +import { Link, useHistory, Redirect } from 'react-router-dom'; +import { useSCE } from '../../Components/context/SceContext'; import { createSCEvent } from '../../APIFunctions/SCEvents.js'; import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock.js'; import { membershipState } from '../../Enums'; +import config from '../../config/config.json'; /** Matches SCEvents `max_attendees` when there is no cap. */ const UNLIMITED_ATTENDEES = -1; @@ -70,6 +71,7 @@ function toApiRegistrationForm(questions) { export default function CreateEventPage() { const { user } = useSCE(); const history = useHistory(); + const isSCEventsEnabled = config.SCEvents?.ENABLED; const [eventId] = useState(() => crypto.randomUUID()); const [eventName, setEventName] = useState(''); @@ -240,6 +242,10 @@ export default function CreateEventPage() { history.push('/events'); } + if (!isSCEventsEnabled) { + return ; + } + if (!isOfficerOrAdmin) { return (
diff --git a/src/Pages/Events/EditEventPage.js b/src/Pages/Events/EditEventPage.js index 9ce6b0cd3..36373d865 100644 --- a/src/Pages/Events/EditEventPage.js +++ b/src/Pages/Events/EditEventPage.js @@ -1,10 +1,11 @@ /* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */ import React, { useMemo, useState, useEffect } from 'react'; -import { Link, useHistory, useParams } from 'react-router-dom'; -import { useSCE } from '../../Components/context/SceContext.js'; +import { Link, useHistory, useParams, Redirect } from 'react-router-dom'; +import { useSCE } from '../../Components/context/SceContext'; import { getEventByID, updateSCEvent } from '../../APIFunctions/SCEvents.js'; import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock.js'; import { membershipState } from '../../Enums'; +import config from '../../config/config.json'; /** Matches SCEvents `max_attendees` when there is no cap. */ const UNLIMITED_ATTENDEES = -1; @@ -45,6 +46,7 @@ export default function EditEventPage() { const { id } = useParams(); const { user } = useSCE(); const history = useHistory(); + const isSCEventsEnabled = config.SCEvents?.ENABLED; const [isLoading, setIsLoading] = useState(true); const [fetchError, setFetchError] = useState(''); @@ -103,8 +105,12 @@ export default function EditEventPage() { setEventAdmins(evt.admins || []); } + if (!isSCEventsEnabled) { + return; + } + loadEvent(); - }, [id]); + }, [id, isSCEventsEnabled]); function addQuestion() { setQuestions((prev) => [...prev, newQuestionTemplate()]); @@ -239,6 +245,10 @@ export default function EditEventPage() { history.push('/events'); } + if (!isSCEventsEnabled) { + return ; + } + if (!isOfficerOrAdmin) { return (
diff --git a/src/Pages/Events/EventAttendeesDashboard.js b/src/Pages/Events/EventAttendeesDashboard.js new file mode 100644 index 000000000..3b9f718eb --- /dev/null +++ b/src/Pages/Events/EventAttendeesDashboard.js @@ -0,0 +1,186 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, Redirect, useParams } from 'react-router-dom'; +import config from '../../config/config.json'; +import { getEventRegistrationByRequestId, getEventRegistrations } from '../../APIFunctions/SCEvents'; +import { useSCE } from '../../Components/context/SceContext'; + +function formatDateTime(dateValue) { + if (!dateValue) return 'N/A'; + const date = new Date(dateValue); + if (isNaN(date)) return 'N/A'; + return date.toLocaleString(); +} + +function SummaryCard({ label, value }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function AnswerValue({ value }) { + if (Array.isArray(value)) return {value.join(', ') || 'N/A'}; + if (value === null || value === undefined || value === '') return N/A; + if (typeof value === 'object') return {JSON.stringify(value)}; + return {String(value)}; +} + +export default function EventAttendeesDashboard() { + const { id } = useParams(); + const { user, authenticated } = useSCE(); + const isSCEventsEnabled = Boolean(config.SCEvents?.ENABLED); + + const [isLoadingList, setIsLoadingList] = useState(true); + const [listError, setListError] = useState(''); + const [attendees, setAttendees] = useState([]); + const [summary, setSummary] = useState({ total: 0, accepted: 0, pending: 0, rejected: 0 }); + + const [selectedRequestId, setSelectedRequestId] = useState(''); + const [selectedAttendee, setSelectedAttendee] = useState(null); + const [isLoadingDetail, setIsLoadingDetail] = useState(false); + const [detailError, setDetailError] = useState(''); + + useEffect(() => { + if (!authenticated || !user?.token || !id) return; + + async function fetchRegistrations() { + setIsLoadingList(true); + setListError(''); + const response = await getEventRegistrations(id, user.token, { limit: 100, offset: 0 }); + if (response.error) { + setListError(response.responseData?.error || 'Failed to load attendees.'); + setAttendees([]); + setSummary({ total: 0, accepted: 0, pending: 0, rejected: 0 }); + } else { + setAttendees(Array.isArray(response.responseData?.attendees) ? response.responseData.attendees : []); + setSummary(response.responseData?.summary || { total: 0, accepted: 0, pending: 0, rejected: 0 }); + } + setIsLoadingList(false); + } + + fetchRegistrations(); + }, [authenticated, id, user?.token]); + + useEffect(() => { + if (!selectedRequestId || !user?.token) return; + + async function fetchAttendeeDetail() { + setIsLoadingDetail(true); + setDetailError(''); + const response = await getEventRegistrationByRequestId(id, selectedRequestId, user.token); + if (response.error) { + setDetailError(response.responseData?.error || 'Failed to load attendee details.'); + setSelectedAttendee(null); + } else { + setSelectedAttendee(response.responseData); + } + setIsLoadingDetail(false); + } + + fetchAttendeeDetail(); + }, [id, selectedRequestId, user?.token]); + + const selectedAnswers = useMemo(() => { + if (!selectedAttendee?.answers || typeof selectedAttendee.answers !== 'object') return []; + return Object.entries(selectedAttendee.answers); + }, [selectedAttendee]); + + if (!isSCEventsEnabled) return ; + if (!authenticated) return ; + + return ( +
+
+
+
+

Event Attendees Dashboard

+

Event ID: {id}

+
+ + Back to Events + +
+ +
+ + + + +
+ + {isLoadingList &&

Loading attendees...

} + {!isLoadingList && listError &&

{listError}

} + + {!isLoadingList && !listError && ( +
+
+

Attendees

+ {attendees.length === 0 ? ( +

No attendees found for this event yet.

+ ) : ( +
+ {attendees.map((attendee) => ( + + ))} +
+ )} +
+ +
+

Attendee Detail

+ {!selectedRequestId &&

Select an attendee to view answers.

} + {isLoadingDetail &&

Loading attendee detail...

} + {!isLoadingDetail && detailError &&

{detailError}

} + {!isLoadingDetail && !detailError && selectedAttendee && ( +
+
+

Name: {selectedAttendee.registrant?.name || 'N/A'}

+

Email: {selectedAttendee.registrant?.email || 'N/A'}

+

Status: {selectedAttendee.status || 'N/A'}

+

Submitted: {formatDateTime(selectedAttendee.created_at)}

+
+ +
+

Registration Form Answers

+ {selectedAnswers.length === 0 ? ( +

No answers submitted.

+ ) : ( + selectedAnswers.map(([fieldKey, value]) => ( +
+

{fieldKey}

+

+
+ )) + )} +
+
+ )} +
+
+ )} +
+
+ ); +} diff --git a/src/Routes.js b/src/Routes.js index 3768c1e3c..37a523bfc 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -23,6 +23,7 @@ import EventsPage from './Pages/Events/Events.js'; import CreateEventPage from './Pages/Events/CreateEventPage.js'; import EventRegistration from './Pages/Events/EventsRegistation.js'; import EditEventPage from './Pages/Events/EditEventPage.js'; +import EventAttendeesDashboard from './Pages/Events/EventAttendeesDashboard.js'; // Declare an enum for permission check export const allowedIf = { @@ -186,6 +187,15 @@ export const officerOrAdminRoutes = [ redirect: '/', inAdminNavbar: false }, + { + Component: EventAttendeesDashboard, + path: '/events/:id/admin/attendees', + pageName: 'Event Attendees Dashboard', + allowedIf: allowedIf.AUTHENTICATED, + redirect: '/login', + inAdminNavbar: false, + hideFromShortcutSuggestions: true + }, { Component: EventsPage, path: '/events', From 8c77f29dc340270a660a2b4e4a4e315549f296b3 Mon Sep 17 00:00:00 2001 From: Khoa Ng Date: Wed, 29 Apr 2026 22:05:00 -0700 Subject: [PATCH 2/3] refactor admin event dashboard navigation and attendee details Scope the PR to admin dashboard and attendee list updates by restoring admin-only access/sidebar behavior and moving attendee detail into a click-open side panel. Made-with: Cursor --- src/Components/Navbar/UserNavbar.js | 3 - src/Pages/Events/CalendarView.js | 30 +++--- src/Pages/Events/EventAttendeesDashboard.js | 102 +++++++++++++------- src/Routes.js | 8 +- 4 files changed, 86 insertions(+), 57 deletions(-) diff --git a/src/Components/Navbar/UserNavbar.js b/src/Components/Navbar/UserNavbar.js index 763608282..301860e9d 100644 --- a/src/Components/Navbar/UserNavbar.js +++ b/src/Components/Navbar/UserNavbar.js @@ -1,7 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { membershipState } from '../../Enums'; import { useSCE } from '../context/SceContext'; -import config from '../../config/config.json'; export default function UserNavbar(props) { const { user, authenticated } = useSCE(); @@ -15,14 +14,12 @@ export default function UserNavbar(props) { { title: 'About', route: '/about' }, { title: 'Projects', route: '/projects' }, { title: 'Summer Internship', route: '/s/internship', newTab: true }, - ...(config.SCEvents?.ENABLED ? [{ title: 'Events', route: '/events' }] : []), ]; const authedRoutes = [ { title: 'Printing', route: '/2DPrinting' }, { title: 'Chat', route: '/messaging' }, { title: 'LED Sign', route: '/led-sign' }, - ...(config.SCEvents?.ENABLED ? [{ title: 'Events', route: '/events' }] : []), ]; const authentication = [ diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index 742559de9..058f53dc4 100644 --- a/src/Pages/Events/CalendarView.js +++ b/src/Pages/Events/CalendarView.js @@ -282,10 +282,10 @@ function EventPopup({ event, onClose, isAdminView, user }) { const [waitlistSubmitting, setWaitlistSubmitting] = useState(false); const [waitlistMessage, setWaitlistMessage] = useState(''); const [waitlistError, setWaitlistError] = useState(''); - const canEditEvent = - Array.isArray(event.admins) && - userId && - event.admins.includes(userId); + const eventAdmins = Array.isArray(event.admins) ? event.admins.map((id) => String(id)) : []; + const canManageEvent = + (eventAdmins.length > 0 && userId && eventAdmins.includes(userId)) + || (eventAdmins.length === 0 && (user?.accessLevel ?? 0) >= 4); const [hasRegistered, setHasRegistered] = useState(false); const [isCheckingRegistration, setIsCheckingRegistration] = useState(false); const eventId = event?.id || event?._id; @@ -299,7 +299,7 @@ function EventPopup({ event, onClose, isAdminView, user }) { const registrationCta = getRegistrationCta(event); const isFull = hasCapacityLimit && typeof remainingSpots === 'number' && remainingSpots <= 0; const shouldShowWaitlistJoin = - !canEditEvent && + !canManageEvent && event.status === 'published' && !registrationCta.disabled && isFull && @@ -315,7 +315,7 @@ function EventPopup({ event, onClose, isAdminView, user }) { useEffect(() => { async function fetchMyRegistrationState() { - if (!userId || canEditEvent || !user?.token || event.status !== 'published') { + if (!userId || canManageEvent || !user?.token || event.status !== 'published') { setHasRegistered(false); setIsCheckingRegistration(false); return; @@ -333,7 +333,7 @@ function EventPopup({ event, onClose, isAdminView, user }) { } fetchMyRegistrationState(); - }, [canEditEvent, event, user?.token, userId]); + }, [canManageEvent, event, user?.token, userId]); useEffect(() => { function onClickOutside(e) { @@ -497,7 +497,7 @@ function EventPopup({ event, onClose, isAdminView, user }) {

)} - {hasCapacityLimit && !canEditEvent && typeof remainingSpots === 'number' && ( + {hasCapacityLimit && !canManageEvent && typeof remainingSpots === 'number' && (

{remainingSpots} spot{remainingSpots !== 1 ? 's' : ''} left {event.waitlist_enabled && ( @@ -506,18 +506,18 @@ function EventPopup({ event, onClose, isAdminView, user }) {

)} - {hasCapacityLimit && !canEditEvent && attendanceLoading && ( + {hasCapacityLimit && !canManageEvent && attendanceLoading && (

Loading live spots...

)} - {hasCapacityLimit && !canEditEvent && attendanceLoaded && typeof remainingSpots !== 'number' && ( + {hasCapacityLimit && !canManageEvent && attendanceLoaded && typeof remainingSpots !== 'number' && (

Unable to load live spots

)} - {canEditEvent && ( + {canManageEvent && (
)} - {!canEditEvent && event.status === 'closed' && ( + {!canManageEvent && event.status === 'closed' && (
Registration closed
)} - {!canEditEvent && event.status === 'draft' && ( + {!canManageEvent && event.status === 'draft' && (
Not yet published
)} - {!canEditEvent && event.status === 'published' && registrationCta.disabled && ( + {!canManageEvent && event.status === 'published' && registrationCta.disabled && (
)} - {!shouldShowWaitlistJoin && !canEditEvent && event.status === 'published' && !registrationCta.disabled && ( + {!shouldShowWaitlistJoin && !canManageEvent && event.status === 'published' && !registrationCta.disabled && ( { if (!authenticated || !user?.token || !id) return; @@ -82,6 +83,19 @@ export default function EventAttendeesDashboard() { fetchAttendeeDetail(); }, [id, selectedRequestId, user?.token]); + useEffect(() => { + if (selectedRequestId) setIsDetailOpen(true); + }, [selectedRequestId]); + + function handleSelectAttendee(requestId) { + setSelectedRequestId(requestId); + setDetailError(''); + } + + function closeDetailPanel() { + setIsDetailOpen(false); + } + const selectedAnswers = useMemo(() => { if (!selectedAttendee?.answers || typeof selectedAttendee.answers !== 'object') return []; return Object.entries(selectedAttendee.answers); @@ -114,9 +128,11 @@ export default function EventAttendeesDashboard() { {!isLoadingList && listError &&

{listError}

} {!isLoadingList && !listError && ( -
-
-

Attendees

+
+
+

Attendees

+

Click an attendee to open details

+
{attendees.length === 0 ? (

No attendees found for this event yet.

) : ( @@ -130,7 +146,7 @@ export default function EventAttendeesDashboard() { ? 'border-sky-400 bg-sky-500/10' : 'border-white/10 bg-black/10 hover:bg-white/10', ].join(' ')} - onClick={() => setSelectedRequestId(attendee.request_id)} + onClick={() => handleSelectAttendee(attendee.request_id)} >
@@ -146,41 +162,57 @@ export default function EventAttendeesDashboard() { ))}
)} +
+ )} +
+ + {isDetailOpen && ( +
+
event.stopPropagation()} + > +
+

Attendee Detail

+
-
-

Attendee Detail

- {!selectedRequestId &&

Select an attendee to view answers.

} - {isLoadingDetail &&

Loading attendee detail...

} - {!isLoadingDetail && detailError &&

{detailError}

} - {!isLoadingDetail && !detailError && selectedAttendee && ( -
-
-

Name: {selectedAttendee.registrant?.name || 'N/A'}

-

Email: {selectedAttendee.registrant?.email || 'N/A'}

-

Status: {selectedAttendee.status || 'N/A'}

-

Submitted: {formatDateTime(selectedAttendee.created_at)}

-
- -
-

Registration Form Answers

- {selectedAnswers.length === 0 ? ( -

No answers submitted.

- ) : ( - selectedAnswers.map(([fieldKey, value]) => ( -
-

{fieldKey}

-

-
- )) - )} -
+ {!selectedRequestId &&

Select an attendee to view answers.

} + {isLoadingDetail &&

Loading attendee detail...

} + {!isLoadingDetail && detailError &&

{detailError}

} + {!isLoadingDetail && !detailError && selectedAttendee && ( +
+
+

Name: {selectedAttendee.registrant?.name || 'N/A'}

+

Email: {selectedAttendee.registrant?.email || 'N/A'}

+

Status: {selectedAttendee.status || 'N/A'}

+

Submitted: {formatDateTime(selectedAttendee.created_at)}

- )} -
+ +
+

Registration Form Answers

+ {selectedAnswers.length === 0 ? ( +

No answers submitted.

+ ) : ( + selectedAnswers.map(([fieldKey, value]) => ( +
+

{fieldKey}

+

+
+ )) + )} +
+
+ )}
- )} -
+
+ )}
); } diff --git a/src/Routes.js b/src/Routes.js index 37a523bfc..12bdc66af 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -185,15 +185,15 @@ export const officerOrAdminRoutes = [ pageName: 'Edit Event', allowedIf: allowedIf.OFFICER_OR_ADMIN, redirect: '/', - inAdminNavbar: false + inAdminNavbar: true }, { Component: EventAttendeesDashboard, path: '/events/:id/admin/attendees', pageName: 'Event Attendees Dashboard', - allowedIf: allowedIf.AUTHENTICATED, - redirect: '/login', - inAdminNavbar: false, + allowedIf: allowedIf.OFFICER_OR_ADMIN, + redirect: '/', + inAdminNavbar: true, hideFromShortcutSuggestions: true }, { From 46f257c5a0981bfbe12ca25cd332bce41ce16595 Mon Sep 17 00:00:00 2001 From: Khoa Ng Date: Wed, 29 Apr 2026 22:07:24 -0700 Subject: [PATCH 3/3] fix attendee dashboard lint indentation Adjust JSX indentation in the attendee list block so lint and production build pass in CI. Made-with: Cursor --- src/Pages/Events/EventAttendeesDashboard.js | 56 ++++++++++----------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Pages/Events/EventAttendeesDashboard.js b/src/Pages/Events/EventAttendeesDashboard.js index d75881199..bbef9d660 100644 --- a/src/Pages/Events/EventAttendeesDashboard.js +++ b/src/Pages/Events/EventAttendeesDashboard.js @@ -133,35 +133,35 @@ export default function EventAttendeesDashboard() {

Attendees

Click an attendee to open details

- {attendees.length === 0 ? ( -

No attendees found for this event yet.

- ) : ( -
- {attendees.map((attendee) => ( - - ))} -
- )} +
+

{attendee.status || 'unknown'}

+

{formatDateTime(attendee.created_at)}

+
+
+ + ))} +
+ )}
)}