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 ( + + + + + {request.companyName || "Hackathon Request"} + + s.value === (request.status || "pending"))?.color || "default"} + size="small" + sx={{ textTransform: "capitalize" }} + /> + + + + + + + + {/* Admin Controls */} + + + Admin Controls + + + + + Status + + + + + setAdminNotes(e.target.value)} + multiline + rows={2} + /> + + + {editUrl && ( + + + + )} + + + {/* Contact Information */} +
+ + + + + + + } + /> + + + + + + + + + + + +
+ + {/* Event Details */} +
+ + + + + + + } + /> + + + + + + + + + + + + + + + + + + + + +
+ + {/* Nonprofit Engagement */} +
+ + + + + + + + + + + {request.nonprofitDetails && ( + + + + )} + + + + +
+ + {/* Responsibilities */} + {request.responsibilities && ( +
+ + {Object.entries(request.responsibilities).map(([key, value]) => ( + + + + {responsibilityLabels[key] || key} + + + + + ))} + +
+ )} + + {/* Budget */} +
+ + + + + + + + + + + +
+ + {/* Additional Info */} + {request.additionalInfo && ( +
+ + {request.additionalInfo} + +
+ )} + + {/* Metadata */} + + + + Submitted: {formatDate(request.created)} + + {request.updated && ( + + Last Updated: {formatDate(request.updated)} + + )} + + ID: {request.id} + + +
+ + + + + +
+ ); +}; + +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;