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/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js index 69dc9fc21..058f53dc4 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); @@ -284,10 +282,14 @@ 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; + const authToken = user?.token; const maxAttendees = Number(event.max_attendees); const hasCapacityLimit = Number.isFinite(maxAttendees) && maxAttendees > 0; const remainingSpots = @@ -297,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 && @@ -311,6 +313,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,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 )}

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

{remainingSpots} spot{remainingSpots !== 1 ? 's' : ''} left {event.waitlist_enabled && ( @@ -483,41 +506,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 && ( 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..bbef9d660 --- /dev/null +++ b/src/Pages/Events/EventAttendeesDashboard.js @@ -0,0 +1,218 @@ +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(''); + 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 (!isSCEventsEnabled) return ; + 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/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,