diff --git a/src/APIFunctions/SCEvents.js b/src/APIFunctions/SCEvents.js index 3e2cd82bd..7f9b999e3 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,36 @@ 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 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..e18b75c14 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -42,16 +42,17 @@ export default function UserNavBar(props) { }, ]; - const sceventsAdminNavLinks = []; - sceventsAdminNavLinks.push({ - title: 'Events', - route: '/events', - icon: ( - - - - ), - }); + const sceventsAdminNavLinks = [ + { + title: 'Events', + route: '/events', + icon: ( + + + + ), + }, + ]; const adminLinks = [ { diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index 69dc9fc21..008b009ff 100644 --- a/src/Pages/Events/CalendarView.js +++ b/src/Pages/Events/CalendarView.js @@ -1,6 +1,7 @@ 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'; +import { membershipState } from '../../Enums'; // ─── tiny helpers ──────────────────────────────────────────────────────────── @@ -199,6 +200,29 @@ function getRegistrationCta(event) { } } +/** + * Checks if a user has permission to manage an event. + * @param {Object} event The event object. + * @param {Object} user The user object. + * @returns {boolean} True if the user can manage the event. + */ +function canUserManageEvent(event, user) { + const userId = user?._id != null ? String(user._id) : ''; + const eventAdmins = Array.isArray(event.admins) ? event.admins.map((id) => String(id)) : []; + + // 1. If user is explicitly listed as an admin for this event + if (eventAdmins.length > 0 && userId && eventAdmins.includes(userId)) { + return true; + } + + // 2. If the event has no admins, allow users with level 3 (ADMIN) or higher to manage it + if (eventAdmins.length === 0 && (user?.accessLevel ?? 0) >= membershipState.ADMIN) { + return true; + } + + return false; +} + // ─── icons ──────────────────────────────────────────────────────────────────── function ChevronLeft() { @@ -275,8 +299,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); @@ -284,10 +306,11 @@ 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 canManageEvent = canUserManageEvent(event, user); + 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 = @@ -297,7 +320,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 && @@ -311,6 +334,28 @@ function EventPopup({ event, onClose, isAdminView, user }) { return () => document.removeEventListener('keydown', onKey); }, [onClose]); + useEffect(() => { + async function fetchMyRegistrationState() { + if (!userId || canManageEvent || !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(); + }, [canManageEvent, event, user?.token, userId]); + useEffect(() => { function onClickOutside(e) { if (popupRef.current && !popupRef.current.contains(e.target)) { @@ -386,7 +431,6 @@ 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 )}

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

{remainingSpots} spot{remainingSpots !== 1 ? 's' : ''} left {event.waitlist_enabled && ( @@ -483,41 +527,49 @@ 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 && ( - - Edit event - + {canManageEvent && ( +
+ + View attendees + + + Edit event + +
)} - {!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 && ( +

{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 [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(''); + const [isDetailOpen, setIsDetailOpen] = useState(false); + + 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]); + + 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); + }, [selectedAttendee]); + + if (!authenticated) return ; + + return ( +
+
+
+
+

Event Attendees Dashboard

+

Event ID: {id}

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

Loading attendees...

} + {!isLoadingList && listError &&

{listError}

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

Attendees

+

Click an attendee to open details

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

No attendees found for this event yet.

+ ) : ( +
+ {attendees.map((attendee) => ( + + ))} +
+ )} +
+ )} +
+ + {isDetailOpen && ( +
+
event.stopPropagation()} + > +
+

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/Pages/Events/EventsRegistation.js b/src/Pages/Events/EventsRegistation.js index f04f7d09a..90a31d816 100644 --- a/src/Pages/Events/EventsRegistation.js +++ b/src/Pages/Events/EventsRegistation.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { useSCE } from '../../Components/context/SceContext'; -import { getEventByID, getEventAttendanceSummary, registerForSCEvent } from '../../APIFunctions/SCEvents'; +import { getEventByID, getEventAttendanceSummary, registerForEvent } from '../../APIFunctions/SCEvents'; function ArrowLeftIcon() { return ( @@ -162,7 +162,7 @@ export default function EventRegistration() { }; setSubmitting(true); - const result = await registerForSCEvent(id, token, payload); + const result = await registerForEvent(id, token, payload); setSubmitting(false); if (result.error) { diff --git a/src/Routes.js b/src/Routes.js index 3768c1e3c..12bdc66af 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 = { @@ -184,7 +185,16 @@ 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.OFFICER_OR_ADMIN, + redirect: '/', + inAdminNavbar: true, + hideFromShortcutSuggestions: true }, { Component: EventsPage,