diff --git a/src/components/admin/AdminNavigation.js b/src/components/admin/AdminNavigation.js
index cfe66f19..78cfaafd 100644
--- a/src/components/admin/AdminNavigation.js
+++ b/src/components/admin/AdminNavigation.js
@@ -37,8 +37,8 @@ import {
ChevronLeft as ChevronLeftIcon,
Dashboard as DashboardIcon,
Share as ShareIcon,
- Gavel as JudgingIcon,
-
+ Gavel as JudgingIcon,
+ PostAdd as RequestIcon,
} from "@mui/icons-material";
import HandshakeIcon from '@mui/icons-material/Handshake';
@@ -81,6 +81,11 @@ const adminPages = [
label: "Hackathons",
icon:
},
+ {
+ path: "/admin/hackathon-requests",
+ label: "Hackathon Requests",
+ icon:
+ },
{
path: "/admin/check-in",
label: "Check In",
diff --git a/src/components/admin/HackathonRequestDetailDialog.js b/src/components/admin/HackathonRequestDetailDialog.js
new file mode 100644
index 00000000..965d5254
--- /dev/null
+++ b/src/components/admin/HackathonRequestDetailDialog.js
@@ -0,0 +1,392 @@
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Typography,
+ Box,
+ Grid,
+ Chip,
+ Divider,
+ TextField,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Paper,
+ IconButton,
+ useMediaQuery,
+ useTheme,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+import OpenInNewIcon from "@mui/icons-material/OpenInNew";
+
+const statusOptions = [
+ { value: "pending", label: "Pending", color: "warning" },
+ { value: "approved", label: "Approved", color: "success" },
+ { value: "in-progress", label: "In Progress", color: "info" },
+ { value: "completed", label: "Completed", color: "success" },
+ { value: "rejected", label: "Rejected", color: "error" },
+];
+
+const formatDate = (dateStr) => {
+ if (!dateStr) return "Not set";
+ try {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return dateStr;
+ return d.toLocaleDateString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ } catch {
+ return dateStr;
+ }
+};
+
+const getResponsibilityLabel = (value) => {
+ switch (value) {
+ case "requestor":
+ return "Their Organization";
+ case "ohack":
+ return "Opportunity Hack";
+ case "shared":
+ return "Shared";
+ default:
+ return value || "-";
+ }
+};
+
+const responsibilityLabels = {
+ venue: "Venue & Equipment",
+ food: "Food & Refreshments",
+ prizes: "Prizes & Swag",
+ judges: "Judges",
+ mentors: "Technical Mentors",
+ marketing: "Marketing & Communications",
+ nonprofitRecruitment: "Nonprofit Recruitment",
+ participantRecruitment: "Participant Recruitment",
+ postEventSupport: "Post-Event Support",
+};
+
+const Section = ({ title, children }) => (
+
+
+ {title}
+
+
+ {children}
+
+
+);
+
+const Field = ({ label, value }) => (
+
+
+ {label}
+
+ {value || "-"}
+
+);
+
+const HackathonRequestDetailDialog = ({ open, onClose, request, onSave }) => {
+ const theme = useTheme();
+ const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
+ const [status, setStatus] = useState("");
+ const [adminNotes, setAdminNotes] = useState("");
+
+ useEffect(() => {
+ if (request) {
+ setStatus(request.status || "pending");
+ setAdminNotes(request.adminNotes || "");
+ }
+ }, [request]);
+
+ if (!request) return null;
+
+ const handleSave = () => {
+ onSave({
+ id: request.id,
+ status,
+ adminNotes,
+ });
+ };
+
+ const participantTypes = Array.isArray(request.participantType)
+ ? request.participantType.join(", ").replace(/-/g, " ")
+ : request.participantType || "-";
+
+ const nonprofitSources = Array.isArray(request.nonprofitSource)
+ ? request.nonprofitSource.join(", ").replace(/-/g, " ")
+ : request.nonprofitSource || "-";
+
+ const editUrl = request.id ? `/hack/request/${request.id}` : null;
+
+ return (
+
+ );
+};
+
+export default HackathonRequestDetailDialog;
diff --git a/src/components/admin/HackathonRequestTable.js b/src/components/admin/HackathonRequestTable.js
new file mode 100644
index 00000000..97c70af8
--- /dev/null
+++ b/src/components/admin/HackathonRequestTable.js
@@ -0,0 +1,175 @@
+import React from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+ Paper,
+ Button,
+ Chip,
+ Tooltip,
+ Box,
+} from "@mui/material";
+import { styled } from "@mui/system";
+
+const StyledTableContainer = styled(TableContainer)(({ theme }) => ({
+ width: "100%",
+ overflowX: "auto",
+ "& .MuiTable-root": {
+ minWidth: "100%",
+ },
+}));
+
+const StyledTableCell = styled(TableCell)(({ theme }) => ({
+ padding: theme.spacing(1, 2),
+ [theme.breakpoints.down("md")]: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ borderBottom: "none",
+ padding: theme.spacing(1, 2),
+ "&:before": {
+ content: "attr(data-label)",
+ fontWeight: "bold",
+ marginBottom: theme.spacing(0.5),
+ },
+ },
+}));
+
+const StyledTableRow = styled(TableRow)(({ theme }) => ({
+ [theme.breakpoints.down("md")]: {
+ display: "flex",
+ flexDirection: "column",
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ },
+}));
+
+const statusColorMap = {
+ pending: "warning",
+ approved: "success",
+ rejected: "error",
+ "in-progress": "info",
+ completed: "success",
+};
+
+const columns = [
+ { id: "companyName", label: "Organization", minWidth: 140 },
+ { id: "contactName", label: "Contact", minWidth: 120 },
+ { id: "contactEmail", label: "Email", minWidth: 160 },
+ { id: "organizationType", label: "Type", minWidth: 100 },
+ { id: "employeeCount", label: "Participants", minWidth: 100 },
+ { id: "eventFormat", label: "Format", minWidth: 90 },
+ { id: "location", label: "Location", minWidth: 120 },
+ { id: "expectedHackathonDate", label: "Hackathon Date", minWidth: 120 },
+ { id: "status", label: "Status", minWidth: 100 },
+ { id: "created", label: "Submitted", minWidth: 120 },
+];
+
+const formatDate = (dateStr) => {
+ if (!dateStr) return "-";
+ try {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return dateStr;
+ return d.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ } catch {
+ return dateStr;
+ }
+};
+
+const HackathonRequestTable = ({
+ requests,
+ orderBy,
+ order,
+ onRequestSort,
+ onViewRequest,
+}) => {
+ return (
+
+
+
+
+ {columns.map((column) => (
+
+ onRequestSort(column.id)}
+ >
+ {column.label}
+
+
+ ))}
+ Actions
+
+
+
+ {requests.length === 0 ? (
+
+
+ No hackathon requests found.
+
+
+ ) : (
+ requests.map((req) => (
+
+ {columns.map((column) => (
+
+ {column.id === "status" ? (
+
+ ) : column.id === "created" ||
+ column.id === "expectedHackathonDate" ? (
+ formatDate(req[column.id])
+ ) : column.id === "organizationType" ? (
+
+ ) : column.id === "employeeCount" ? (
+ req[column.id] ? `~${req[column.id]}` : "-"
+ ) : (
+
+
+ {(req[column.id] || "-").toString().length > 30
+ ? (req[column.id] || "").toString().substring(0, 30) + "..."
+ : req[column.id] || "-"}
+
+
+ )}
+
+ ))}
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+};
+
+export default HackathonRequestTable;
diff --git a/src/components/admin/__tests__/HackathonRequestDetailDialog.test.js b/src/components/admin/__tests__/HackathonRequestDetailDialog.test.js
new file mode 100644
index 00000000..15c73398
--- /dev/null
+++ b/src/components/admin/__tests__/HackathonRequestDetailDialog.test.js
@@ -0,0 +1,192 @@
+import React from "react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider, createTheme } from "@mui/material";
+import HackathonRequestDetailDialog from "../HackathonRequestDetailDialog";
+
+const theme = createTheme();
+
+const mockRequest = {
+ id: "req-123",
+ companyName: "Test Corporation",
+ organizationType: "corporate",
+ contactName: "Jane Doe",
+ contactEmail: "jane@testcorp.com",
+ contactPhone: "555-0123",
+ employeeCount: "200",
+ eventFormat: "in-person",
+ location: "San Francisco, CA",
+ participantType: ["internal-staff", "students"],
+ hackathonTheme: "social-impact",
+ expectedHackathonDate: "2025-11-15T00:00:00",
+ preferredDate: "2025-08-08T00:00:00",
+ alternateDate: "2025-08-15T00:00:00",
+ hasNonprofitList: "partial",
+ hasWorkedWithNonprofitsBefore: "yes",
+ nonprofitDetails: "Local food bank, animal shelter",
+ nonprofitSource: ["ohack-support"],
+ preferredNonprofitLocation: "local",
+ responsibilities: {
+ venue: "requestor",
+ food: "requestor",
+ prizes: "shared",
+ judges: "ohack",
+ mentors: "shared",
+ marketing: "requestor",
+ nonprofitRecruitment: "shared",
+ participantRecruitment: "requestor",
+ postEventSupport: "shared",
+ },
+ budget: 25000,
+ donationPercentage: 20,
+ additionalInfo: "We want to focus on education nonprofits.",
+ status: "pending",
+ created: "2025-07-01T10:00:00",
+ updated: "2025-07-05T14:30:00",
+ adminNotes: "",
+};
+
+const renderDialog = (props = {}) => {
+ return render(
+
+
+
+ );
+};
+
+describe("HackathonRequestDetailDialog", () => {
+ const mockOnClose = jest.fn();
+ const mockOnSave = jest.fn();
+
+ beforeEach(() => {
+ mockOnClose.mockClear();
+ mockOnSave.mockClear();
+ });
+
+ it("should not render when request is null", () => {
+ const { container } = render(
+
+
+
+ );
+
+ expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument();
+ });
+
+ it("should display organization name in title", () => {
+ renderDialog();
+ const matches = screen.getAllByText("Test Corporation");
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should display contact information", () => {
+ renderDialog();
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ expect(screen.getByText("jane@testcorp.com")).toBeInTheDocument();
+ expect(screen.getByText("555-0123")).toBeInTheDocument();
+ });
+
+ it("should display event details", () => {
+ renderDialog();
+ expect(screen.getByText("~200")).toBeInTheDocument();
+ expect(screen.getByText("San Francisco, CA")).toBeInTheDocument();
+ });
+
+ it("should display budget information", () => {
+ renderDialog();
+ expect(screen.getByText("$25,000")).toBeInTheDocument();
+ expect(screen.getByText("20%")).toBeInTheDocument();
+ expect(screen.getByText("$5,000")).toBeInTheDocument(); // 25000 * 0.20
+ });
+
+ it("should display additional information", () => {
+ renderDialog();
+ expect(
+ screen.getByText("We want to focus on education nonprofits.")
+ ).toBeInTheDocument();
+ });
+
+ it("should display request ID", () => {
+ renderDialog();
+ expect(screen.getByText(/req-123/)).toBeInTheDocument();
+ });
+
+ it("should display responsibility divisions", () => {
+ renderDialog();
+ expect(screen.getByText("Venue & Equipment")).toBeInTheDocument();
+ expect(screen.getByText("Food & Refreshments")).toBeInTheDocument();
+ expect(screen.getByText("Prizes & Swag")).toBeInTheDocument();
+ });
+
+ it("should show admin controls with status selector", () => {
+ renderDialog();
+ expect(screen.getByText("Admin Controls")).toBeInTheDocument();
+ const statusElements = screen.getAllByText("Status");
+ expect(statusElements.length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("Admin Notes").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should show Open Edit Form link", () => {
+ renderDialog();
+ expect(screen.getByText("Open Edit Form")).toBeInTheDocument();
+ });
+
+ it("should call onSave when Save Changes is clicked", async () => {
+ const user = userEvent.setup();
+ renderDialog({ onSave: mockOnSave });
+
+ // Click save
+ await user.click(screen.getByText("Save Changes"));
+
+ expect(mockOnSave).toHaveBeenCalledWith({
+ id: "req-123",
+ status: "pending",
+ adminNotes: "",
+ });
+ });
+
+ it("should call onClose when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+ renderDialog({ onClose: mockOnClose });
+
+ await user.click(screen.getByText("Cancel"));
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+
+ it("should display nonprofit engagement details", () => {
+ renderDialog();
+ expect(screen.getByText("Partial")).toBeInTheDocument();
+ expect(screen.getByText("Yes")).toBeInTheDocument(); // hasWorkedWithNonprofitsBefore
+ expect(
+ screen.getByText("Local food bank, animal shelter")
+ ).toBeInTheDocument();
+ });
+
+ it("should handle request with no responsibilities", () => {
+ const requestNoResp = { ...mockRequest, responsibilities: undefined };
+ renderDialog({ request: requestNoResp });
+ // Should not crash and should not show responsibilities section
+ expect(screen.queryByText("Venue & Equipment")).not.toBeInTheDocument();
+ });
+
+ it("should handle request with no additional info", () => {
+ const requestNoInfo = { ...mockRequest, additionalInfo: "" };
+ renderDialog({ request: requestNoInfo });
+ // Should not show additional information section
+ expect(
+ screen.queryByText("Additional Information")
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/admin/__tests__/HackathonRequestTable.test.js b/src/components/admin/__tests__/HackathonRequestTable.test.js
new file mode 100644
index 00000000..208e4322
--- /dev/null
+++ b/src/components/admin/__tests__/HackathonRequestTable.test.js
@@ -0,0 +1,160 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import HackathonRequestTable from "../HackathonRequestTable";
+
+const mockRequests = [
+ {
+ id: "req-1",
+ companyName: "Acme Corp",
+ contactName: "Alice Smith",
+ contactEmail: "alice@acme.com",
+ organizationType: "corporate",
+ employeeCount: "100",
+ eventFormat: "in-person",
+ location: "Phoenix, AZ",
+ expectedHackathonDate: "2025-09-15T00:00:00",
+ status: "pending",
+ created: "2025-06-01T10:00:00",
+ },
+ {
+ id: "req-2",
+ companyName: "State University",
+ contactName: "Bob Jones",
+ contactEmail: "bob@university.edu",
+ organizationType: "university",
+ employeeCount: "200",
+ eventFormat: "virtual",
+ location: "Austin, TX",
+ expectedHackathonDate: "2025-10-20T00:00:00",
+ status: "approved",
+ created: "2025-07-15T14:30:00",
+ },
+];
+
+describe("HackathonRequestTable", () => {
+ const mockOnRequestSort = jest.fn();
+ const mockOnViewRequest = jest.fn();
+
+ const defaultProps = {
+ requests: mockRequests,
+ orderBy: "created",
+ order: "desc",
+ onRequestSort: mockOnRequestSort,
+ onViewRequest: mockOnViewRequest,
+ };
+
+ beforeEach(() => {
+ mockOnRequestSort.mockClear();
+ mockOnViewRequest.mockClear();
+ });
+
+ it("should render table with column headers", () => {
+ render();
+
+ expect(screen.getByText("Organization")).toBeInTheDocument();
+ expect(screen.getByText("Contact")).toBeInTheDocument();
+ expect(screen.getByText("Email")).toBeInTheDocument();
+ expect(screen.getByText("Status")).toBeInTheDocument();
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
+ expect(screen.getByText("Actions")).toBeInTheDocument();
+ });
+
+ it("should render request data in rows", () => {
+ render();
+
+ expect(screen.getByText("Acme Corp")).toBeInTheDocument();
+ expect(screen.getByText("Alice Smith")).toBeInTheDocument();
+ expect(screen.getByText("alice@acme.com")).toBeInTheDocument();
+ expect(screen.getByText("State University")).toBeInTheDocument();
+ expect(screen.getByText("Bob Jones")).toBeInTheDocument();
+ });
+
+ it("should render status chips", () => {
+ render();
+
+ expect(screen.getByText("pending")).toBeInTheDocument();
+ expect(screen.getByText("approved")).toBeInTheDocument();
+ });
+
+ it("should render organization type chips", () => {
+ render();
+
+ expect(screen.getByText("corporate")).toBeInTheDocument();
+ expect(screen.getByText("university")).toBeInTheDocument();
+ });
+
+ it("should format participant count with tilde prefix", () => {
+ render();
+
+ expect(screen.getByText("~100")).toBeInTheDocument();
+ expect(screen.getByText("~200")).toBeInTheDocument();
+ });
+
+ it("should render View buttons for each row", () => {
+ render();
+
+ const viewButtons = screen.getAllByText("View");
+ expect(viewButtons).toHaveLength(2);
+ });
+
+ it("should call onViewRequest when View button is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const viewButtons = screen.getAllByText("View");
+ await user.click(viewButtons[0]);
+
+ expect(mockOnViewRequest).toHaveBeenCalledWith(mockRequests[0]);
+ });
+
+ it("should call onRequestSort when column header is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText("Organization"));
+
+ expect(mockOnRequestSort).toHaveBeenCalledWith("companyName");
+ });
+
+ it("should show empty message when no requests", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("No hackathon requests found.")).toBeInTheDocument();
+ });
+
+ it("should format dates in readable format", () => {
+ render();
+
+ // The exact format depends on locale, but it should contain the month
+ expect(screen.getByText(/Jun.*2025/)).toBeInTheDocument();
+ });
+
+ it("should show dash for missing field values", () => {
+ const requestWithMissing = [
+ {
+ id: "req-3",
+ companyName: "Missing Fields Co",
+ status: "pending",
+ created: "2025-01-01T00:00:00",
+ },
+ ];
+
+ render(
+
+ );
+
+ expect(screen.getByText("Missing Fields Co")).toBeInTheDocument();
+ // Multiple dashes for missing fields
+ const dashes = screen.getAllByText("-");
+ expect(dashes.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/pages/admin/hackathon-requests/__tests__/hackathon-requests.test.js b/src/pages/admin/hackathon-requests/__tests__/hackathon-requests.test.js
new file mode 100644
index 00000000..8dcc1137
--- /dev/null
+++ b/src/pages/admin/hackathon-requests/__tests__/hackathon-requests.test.js
@@ -0,0 +1,261 @@
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ThemeProvider, createTheme } from "@mui/material";
+import AdminHackathonRequestsPage from "../index";
+
+const theme = createTheme();
+
+// Mock PropelAuth
+const mockAccessToken = "test-token-123";
+const mockOrgId = "org-456";
+const mockOrg = {
+ hasPermission: jest.fn().mockReturnValue(true),
+ orgId: mockOrgId,
+};
+const mockUserClass = {
+ getOrgByName: jest.fn().mockReturnValue(mockOrg),
+};
+
+jest.mock("@propelauth/react", () => ({
+ useAuthInfo: () => ({ accessToken: mockAccessToken }),
+ withRequiredAuthInfo: (Component) => (props) =>
+ Component({ ...props, userClass: mockUserClass }),
+}));
+
+// Mock fetch
+const mockRequests = [
+ {
+ id: "req-1",
+ companyName: "Alpha Corp",
+ contactName: "Alice",
+ contactEmail: "alice@alpha.com",
+ organizationType: "corporate",
+ employeeCount: "100",
+ eventFormat: "in-person",
+ location: "Phoenix, AZ",
+ status: "pending",
+ created: "2025-06-01T10:00:00",
+ },
+ {
+ id: "req-2",
+ companyName: "Beta University",
+ contactName: "Bob",
+ contactEmail: "bob@beta.edu",
+ organizationType: "university",
+ employeeCount: "200",
+ eventFormat: "virtual",
+ location: "Austin, TX",
+ status: "approved",
+ created: "2025-07-01T10:00:00",
+ },
+ {
+ id: "req-3",
+ companyName: "Gamma Group",
+ contactName: "Carol",
+ contactEmail: "carol@gamma.org",
+ organizationType: "community",
+ employeeCount: "50",
+ eventFormat: "hybrid",
+ location: "Denver, CO",
+ status: "pending",
+ created: "2025-08-01T10:00:00",
+ },
+];
+
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+const renderPage = () =>
+ render(
+
+
+
+ );
+
+beforeEach(() => {
+ mockFetch.mockClear();
+ mockOrg.hasPermission.mockReturnValue(true);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: async () => ({ requests: mockRequests }),
+ });
+});
+
+describe("AdminHackathonRequestsPage", () => {
+ it("should render the page title", async () => {
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Hackathon Request Management")).toBeInTheDocument();
+ });
+ });
+
+ it("should fetch requests on mount", async () => {
+ renderPage();
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/messages/admin/hackathon-requests"),
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.objectContaining({
+ authorization: `Bearer ${mockAccessToken}`,
+ "X-Org-Id": mockOrgId,
+ }),
+ })
+ );
+ });
+ });
+
+ it("should display requests in the table after fetch", async () => {
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ expect(screen.getByText("Beta University")).toBeInTheDocument();
+ expect(screen.getByText("Gamma Group")).toBeInTheDocument();
+ });
+ });
+
+ it("should display status summary chips", async () => {
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Total: 3")).toBeInTheDocument();
+ expect(screen.getByText("pending: 2")).toBeInTheDocument();
+ expect(screen.getByText("approved: 1")).toBeInTheDocument();
+ });
+ });
+
+ it("should filter requests by text search", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ });
+
+ const filterInput = screen.getByLabelText(
+ "Filter by Organization, Contact, Email, or Location"
+ );
+ await user.type(filterInput, "Alpha");
+
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ expect(screen.queryByText("Beta University")).not.toBeInTheDocument();
+ expect(screen.queryByText("Gamma Group")).not.toBeInTheDocument();
+ });
+
+ it("should filter requests by status chip click", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ });
+
+ // Click the "approved" status chip to filter
+ await user.click(screen.getByText("approved: 1"));
+
+ expect(screen.queryByText("Alpha Corp")).not.toBeInTheDocument();
+ expect(screen.getByText("Beta University")).toBeInTheDocument();
+ expect(screen.queryByText("Gamma Group")).not.toBeInTheDocument();
+ });
+
+ it("should show permission denied for non-admin users", async () => {
+ mockOrg.hasPermission.mockReturnValue(false);
+ renderPage();
+
+ expect(
+ screen.getByText("You do not have permission to view this page.")
+ ).toBeInTheDocument();
+ });
+
+ it("should show error snackbar on fetch failure", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Failed to fetch hackathon requests. Please try again.")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should refresh data when Refresh button is clicked", async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ });
+
+ // Clear and setup new mock
+ mockFetch.mockClear();
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ requests: [mockRequests[0]] }),
+ });
+
+ await user.click(screen.getByText("Refresh Data"));
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("should send PATCH request when saving request updates", async () => {
+ const user = userEvent.setup();
+
+ // First call: list, second call: update, third call: re-fetch
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ requests: mockRequests }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ ...mockRequests[0], status: "approved" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ requests: mockRequests }),
+ });
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Alpha Corp")).toBeInTheDocument();
+ });
+
+ // Click View on first request
+ const viewButtons = screen.getAllByText("View");
+ await user.click(viewButtons[0]);
+
+ // The detail dialog should open
+ await waitFor(() => {
+ expect(screen.getByText("Admin Controls")).toBeInTheDocument();
+ });
+
+ // Click Save Changes
+ await user.click(screen.getByText("Save Changes"));
+
+ await waitFor(() => {
+ // req-3 is first in the table because it has the newest created date
+ // and default sort is created desc
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/messages/admin/hackathon-requests/req-3"),
+ expect.objectContaining({
+ method: "PATCH",
+ headers: expect.objectContaining({
+ authorization: `Bearer ${mockAccessToken}`,
+ }),
+ })
+ );
+ });
+ });
+});
diff --git a/src/pages/admin/hackathon-requests/index.js b/src/pages/admin/hackathon-requests/index.js
new file mode 100644
index 00000000..af7adc8f
--- /dev/null
+++ b/src/pages/admin/hackathon-requests/index.js
@@ -0,0 +1,249 @@
+import React, { useState, useEffect } from "react";
+import { useAuthInfo, withRequiredAuthInfo } from "@propelauth/react";
+import {
+ Box,
+ Grid,
+ CircularProgress,
+ TextField,
+ Button,
+ Typography,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Chip,
+} from "@mui/material";
+import AdminPage from "../../../components/admin/AdminPage";
+import HackathonRequestTable from "../../../components/admin/HackathonRequestTable";
+import HackathonRequestDetailDialog from "../../../components/admin/HackathonRequestDetailDialog";
+
+const AdminHackathonRequestsPage = withRequiredAuthInfo(({ userClass }) => {
+ const { accessToken } = useAuthInfo();
+ const [requests, setRequests] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [snackbar, setSnackbar] = useState({
+ open: false,
+ message: "",
+ severity: "success",
+ });
+ const [orderBy, setOrderBy] = useState("created");
+ const [order, setOrder] = useState("desc");
+ const [filter, setFilter] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [detailDialogOpen, setDetailDialogOpen] = useState(false);
+ const [selectedRequest, setSelectedRequest] = useState(null);
+
+ const org = userClass.getOrgByName("Opportunity Hack Org");
+ const isAdmin = org.hasPermission("volunteer.admin");
+ const orgId = org.orgId;
+
+ const fetchRequests = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/admin/hackathon-requests`,
+ {
+ method: "GET",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ "X-Org-Id": orgId,
+ },
+ }
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ setRequests(data.requests || []);
+ } else {
+ throw new Error("Failed to fetch hackathon requests");
+ }
+ } catch (error) {
+ setSnackbar({
+ open: true,
+ message: "Failed to fetch hackathon requests. Please try again.",
+ severity: "error",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (isAdmin) {
+ fetchRequests();
+ }
+ }, [isAdmin, accessToken]);
+
+ const handleRequestSort = (property) => {
+ const isAsc = orderBy === property && order === "asc";
+ setOrder(isAsc ? "desc" : "asc");
+ setOrderBy(property);
+ };
+
+ const handleViewRequest = (request) => {
+ setSelectedRequest(request);
+ setDetailDialogOpen(true);
+ };
+
+ const handleSaveRequest = async (updatedData) => {
+ setLoading(true);
+ try {
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/admin/hackathon-requests/${updatedData.id}`,
+ {
+ method: "PATCH",
+ headers: {
+ authorization: `Bearer ${accessToken}`,
+ "content-type": "application/json",
+ "X-Org-Id": orgId,
+ },
+ body: JSON.stringify({
+ status: updatedData.status,
+ adminNotes: updatedData.adminNotes,
+ }),
+ }
+ );
+
+ if (response.ok) {
+ setSnackbar({
+ open: true,
+ message: "Hackathon request updated successfully",
+ severity: "success",
+ });
+ fetchRequests();
+ } else {
+ throw new Error("Failed to update hackathon request");
+ }
+ } catch (error) {
+ setSnackbar({
+ open: true,
+ message: "Failed to update hackathon request. Please try again.",
+ severity: "error",
+ });
+ } finally {
+ setLoading(false);
+ setDetailDialogOpen(false);
+ }
+ };
+
+ const handleSnackbarClose = () => {
+ setSnackbar({ ...snackbar, open: false });
+ };
+
+ const sortedAndFilteredRequests = requests
+ .filter((req) => {
+ if (statusFilter !== "all" && req.status !== statusFilter) return false;
+ if (!filter) return true;
+ const searchValue = filter.toLowerCase();
+ return (
+ (req.companyName || "").toLowerCase().includes(searchValue) ||
+ (req.contactName || "").toLowerCase().includes(searchValue) ||
+ (req.contactEmail || "").toLowerCase().includes(searchValue) ||
+ (req.location || "").toLowerCase().includes(searchValue)
+ );
+ })
+ .sort((a, b) => {
+ const valueA = a[orderBy] || "";
+ const valueB = b[orderBy] || "";
+ if (valueA < valueB) return order === "asc" ? -1 : 1;
+ if (valueA > valueB) return order === "asc" ? 1 : -1;
+ return 0;
+ });
+
+ // Count by status for summary chips
+ const statusCounts = requests.reduce((acc, req) => {
+ const s = req.status || "pending";
+ acc[s] = (acc[s] || 0) + 1;
+ return acc;
+ }, {});
+
+ if (!isAdmin) {
+ return (
+
+ You do not have permission to view this page.
+
+ );
+ }
+
+ return (
+
+ {/* Summary chips */}
+
+ setStatusFilter("all")}
+ />
+ {Object.entries(statusCounts).map(([status, count]) => (
+ setStatusFilter(statusFilter === status ? "all" : status)}
+ sx={{ textTransform: "capitalize" }}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ setFilter(e.target.value)}
+ />
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ setDetailDialogOpen(false)}
+ request={selectedRequest}
+ onSave={handleSaveRequest}
+ />
+
+ );
+});
+
+export default AdminHackathonRequestsPage;