diff --git a/web/src/app/reviewer-dashboard/page.tsx b/web/src/app/reviewer-dashboard/page.tsx
new file mode 100644
index 0000000..00a663a
--- /dev/null
+++ b/web/src/app/reviewer-dashboard/page.tsx
@@ -0,0 +1,749 @@
+import { useState, useEffect } from "react";
+import { SubmissionList, SubmissionStatus } from "../ts/Submission";
+import { MapfixList, MapfixStatus } from "../ts/Mapfix";
+import { MapCard } from "../_components/mapCard";
+import Webpage from "@/app/_components/webpage";
+import { ListSortConstants } from "../ts/Sort";
+import { RolesConstants, hasRole, hasAnyReviewerRole } from "../ts/Roles";
+import { useUser } from "@/app/hooks/useUser";
+import {
+ Box,
+ Breadcrumbs,
+ Card,
+ CardContent,
+ Container,
+ Skeleton,
+ Typography,
+ Tabs,
+ Tab,
+ Alert,
+ CircularProgress,
+ Chip,
+ Button
+} from "@mui/material";
+import { Link } from "react-router-dom";
+import NavigateNextIcon from "@mui/icons-material/NavigateNext";
+import { useTitle } from "@/app/hooks/useTitle";
+import AssignmentIcon from "@mui/icons-material/Assignment";
+import BuildIcon from "@mui/icons-material/Build";
+
+interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+function TabPanel(props: TabPanelProps) {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && {children}}
+
+ );
+}
+
+// Helper function to get submission statuses based on user roles
+function getSubmissionStatusesForRoles(roles: number): SubmissionStatus[] {
+ const statuses: SubmissionStatus[] = [];
+
+ if (hasRole(roles, RolesConstants.SubmissionUpload)) {
+ statuses.push(SubmissionStatus.Validated);
+ }
+ if (hasRole(roles, RolesConstants.SubmissionReview)) {
+ statuses.push(SubmissionStatus.Submitted);
+ }
+ if (hasRole(roles, RolesConstants.SubmissionRelease)) {
+ statuses.push(SubmissionStatus.Uploaded);
+ }
+ if (hasRole(roles, RolesConstants.ScriptWrite)) {
+ statuses.push(SubmissionStatus.AcceptedUnvalidated);
+ }
+
+ return statuses;
+}
+
+// Helper function to get mapfix statuses based on user roles
+function getMapfixStatusesForRoles(roles: number): MapfixStatus[] {
+ const statuses: MapfixStatus[] = [];
+
+ if (hasRole(roles, RolesConstants.ScriptWrite)) {
+ statuses.push(MapfixStatus.AcceptedUnvalidated);
+ }
+ if (hasRole(roles, RolesConstants.MapfixUpload)) {
+ statuses.push(MapfixStatus.Validated);
+ statuses.push(MapfixStatus.Uploaded);
+ }
+ if (hasRole(roles, RolesConstants.MapfixReview)) {
+ statuses.push(MapfixStatus.Submitted);
+ }
+
+ return statuses;
+}
+
+// Group submissions by status with priority ordering
+// Priority order: ScriptWrite > SubmissionRelease > SubmissionUpload > SubmissionReview
+function groupSubmissionsByStatus(submissions: any[], roles: number) {
+ const groups: { status: SubmissionStatus; label: string; items: any[]; priority: number }[] = [];
+
+ // Add groups in priority order based on user's roles
+ if (hasRole(roles, RolesConstants.ScriptWrite)) {
+ const items = submissions.filter(s => s.StatusID === SubmissionStatus.AcceptedUnvalidated);
+ if (items.length > 0) {
+ groups.push({ status: SubmissionStatus.AcceptedUnvalidated, label: 'Script Review', items, priority: 1 });
+ }
+ }
+
+ if (hasRole(roles, RolesConstants.SubmissionRelease)) {
+ const items = submissions.filter(s => s.StatusID === SubmissionStatus.Uploaded);
+ if (items.length > 0) {
+ groups.push({ status: SubmissionStatus.Uploaded, label: 'Ready to Release', items, priority: 2 });
+ }
+ }
+
+ if (hasRole(roles, RolesConstants.SubmissionUpload)) {
+ const items = submissions.filter(s => s.StatusID === SubmissionStatus.Validated);
+ if (items.length > 0) {
+ groups.push({ status: SubmissionStatus.Validated, label: 'Ready to Upload', items, priority: 3 });
+ }
+ }
+
+ if (hasRole(roles, RolesConstants.SubmissionReview)) {
+ const items = submissions.filter(s => s.StatusID === SubmissionStatus.Submitted);
+ if (items.length > 0) {
+ groups.push({ status: SubmissionStatus.Submitted, label: 'Pending Review', items, priority: 4 });
+ }
+ }
+
+ return groups;
+}
+
+// Group mapfixes by status with priority ordering
+// Priority order: ScriptWrite > MapfixUpload > MapfixReview
+function groupMapfixesByStatus(mapfixes: any[], roles: number) {
+ const groups: { status: MapfixStatus; label: string; items: any[]; priority: number }[] = [];
+
+ // Add groups in priority order based on user's roles
+ if (hasRole(roles, RolesConstants.ScriptWrite)) {
+ const items = mapfixes.filter(m => m.StatusID === MapfixStatus.AcceptedUnvalidated);
+ if (items.length > 0) {
+ groups.push({ status: MapfixStatus.AcceptedUnvalidated, label: 'Script Review', items, priority: 1 });
+ }
+ }
+
+ if (hasRole(roles, RolesConstants.MapfixUpload)) {
+ const validated = mapfixes.filter(m => m.StatusID === MapfixStatus.Validated);
+ const uploaded = mapfixes.filter(m => m.StatusID === MapfixStatus.Uploaded);
+
+ if (validated.length > 0) {
+ groups.push({ status: MapfixStatus.Validated, label: 'Ready to Upload', items: validated, priority: 2 });
+ }
+ if (uploaded.length > 0) {
+ groups.push({ status: MapfixStatus.Uploaded, label: 'Ready to Release', items: uploaded, priority: 3 });
+ }
+ }
+
+ if (hasRole(roles, RolesConstants.MapfixReview)) {
+ const items = mapfixes.filter(m => m.StatusID === MapfixStatus.Submitted);
+ if (items.length > 0) {
+ groups.push({ status: MapfixStatus.Submitted, label: 'Pending Review', items, priority: 4 });
+ }
+ }
+
+ return groups;
+}
+
+export default function ReviewerDashboardPage() {
+ useTitle("Reviewer Dashboard");
+ const { user, isLoading: userLoading } = useUser();
+
+ const [submissions, setSubmissions] = useState(null);
+ const [mapfixes, setMapfixes] = useState(null);
+ const [isLoadingSubmissions, setIsLoadingSubmissions] = useState(false);
+ const [isLoadingMapfixes, setIsLoadingMapfixes] = useState(false);
+ const [tabValue, setTabValue] = useState(0);
+ const [userRoles, setUserRoles] = useState(null);
+
+ // Fetch user roles
+ useEffect(() => {
+ // Fetch roles from API
+ const controller = new AbortController();
+
+ async function fetchRoles() {
+ try {
+ const res = await fetch('/v1/session/roles', { signal: controller.signal });
+ if (res.ok) {
+ const data = await res.json();
+ setUserRoles(data.Roles);
+ }
+ } catch (error) {
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
+ console.error("Error fetching roles:", error);
+ }
+ }
+ }
+
+ if (user) {
+ fetchRoles();
+ }
+
+ return () => controller.abort();
+ }, [user]);
+
+ // Fetch submissions needing review
+ useEffect(() => {
+ const controller = new AbortController();
+
+ async function fetchAllPagesForStatus(status: SubmissionStatus): Promise {
+ const allItems: any[] = [];
+ let page = 1;
+ let hasMore = true;
+ let totalCount = 0;
+
+ while (hasMore) {
+ const res = await fetch(
+ `/v1/submissions?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`,
+ { signal: controller.signal }
+ );
+
+ if (!res.ok) {
+ console.error(`Failed to fetch submissions for status ${status}, page ${page}:`, res.status);
+ break;
+ }
+
+ const data = await res.json();
+
+ // Store the total count from the first response
+ if (page === 1) {
+ totalCount = data.Total || 0;
+ }
+
+ if (data.Submissions && data.Submissions.length > 0) {
+ allItems.push(...data.Submissions);
+ // Check if there are more pages based on the total count
+ hasMore = allItems.length < totalCount;
+ page++;
+ } else {
+ hasMore = false;
+ }
+ }
+
+ return allItems;
+ }
+
+ async function fetchSubmissions() {
+ if (!userRoles) return;
+
+ setIsLoadingSubmissions(true);
+ try {
+ // Get statuses based on user roles and deduplicate
+ const allowedStatuses = getSubmissionStatusesForRoles(userRoles);
+ const uniqueStatuses = Array.from(new Set(allowedStatuses));
+
+ // Fetch all pages for each status in parallel
+ const results = await Promise.all(
+ uniqueStatuses.map(status => fetchAllPagesForStatus(status))
+ );
+
+ // Combine all results
+ const allSubmissions = results.flat();
+
+ setSubmissions({
+ Submissions: allSubmissions,
+ Total: allSubmissions.length
+ });
+ } catch (error) {
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
+ console.error("Error fetching submissions:", error);
+ }
+ } finally {
+ setIsLoadingSubmissions(false);
+ }
+ }
+
+ if (userRoles && (
+ hasRole(userRoles, RolesConstants.SubmissionReview) ||
+ hasRole(userRoles, RolesConstants.SubmissionUpload) ||
+ hasRole(userRoles, RolesConstants.SubmissionRelease) ||
+ hasRole(userRoles, RolesConstants.ScriptWrite)
+ )) {
+ fetchSubmissions();
+ }
+
+ return () => controller.abort();
+ }, [userRoles]);
+
+ // Fetch mapfixes needing review
+ useEffect(() => {
+ const controller = new AbortController();
+
+ async function fetchAllPagesForStatus(status: MapfixStatus): Promise {
+ const allItems: any[] = [];
+ let page = 1;
+ let hasMore = true;
+ let totalCount = 0;
+
+ while (hasMore) {
+ const res = await fetch(
+ `/v1/mapfixes?Page=${page}&Limit=100&Sort=${ListSortConstants.ListSortDateAscending}&StatusID=${status}`,
+ { signal: controller.signal }
+ );
+
+ if (!res.ok) {
+ console.error(`Failed to fetch mapfixes for status ${status}, page ${page}:`, res.status);
+ break;
+ }
+
+ const data = await res.json();
+
+ // Store the total count from the first response
+ if (page === 1) {
+ totalCount = data.Total || 0;
+ }
+
+ if (data.Mapfixes && data.Mapfixes.length > 0) {
+ allItems.push(...data.Mapfixes);
+ // Check if there are more pages based on the total count
+ hasMore = allItems.length < totalCount;
+ page++;
+ } else {
+ hasMore = false;
+ }
+ }
+
+ return allItems;
+ }
+
+ async function fetchMapfixes() {
+ if (!userRoles) return;
+
+ setIsLoadingMapfixes(true);
+ try {
+ // Get statuses based on user roles and deduplicate
+ const allowedStatuses = getMapfixStatusesForRoles(userRoles);
+ const uniqueStatuses = Array.from(new Set(allowedStatuses));
+
+ // Fetch all pages for each status in parallel
+ const results = await Promise.all(
+ uniqueStatuses.map(status => fetchAllPagesForStatus(status))
+ );
+
+ // Combine all results
+ const allMapfixes = results.flat();
+
+ setMapfixes({
+ Mapfixes: allMapfixes,
+ Total: allMapfixes.length
+ });
+ } catch (error) {
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
+ console.error("Error fetching mapfixes:", error);
+ }
+ } finally {
+ setIsLoadingMapfixes(false);
+ }
+ }
+
+ if (userRoles && (
+ hasRole(userRoles, RolesConstants.MapfixReview) ||
+ hasRole(userRoles, RolesConstants.MapfixUpload) ||
+ hasRole(userRoles, RolesConstants.ScriptWrite)
+ )) {
+ fetchMapfixes();
+ }
+
+ return () => controller.abort();
+ }, [userRoles]);
+
+ const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
+ setTabValue(newValue);
+ };
+
+ const skeletonCards = Array.from({ length: 12 }, (_, i) => i);
+
+ // Check if user is loading
+ if (userLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // Check if user is logged in
+ if (!user) {
+ return (
+
+
+
+ You must be logged in to access the reviewer dashboard.
+
+
+
+ );
+ }
+
+ // Wait for roles to load before checking permissions
+ if (userRoles === null) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // Check if user has any reviewer permissions
+ const canReviewSubmissions = (
+ hasRole(userRoles, RolesConstants.SubmissionReview) ||
+ hasRole(userRoles, RolesConstants.SubmissionUpload) ||
+ hasRole(userRoles, RolesConstants.SubmissionRelease) ||
+ hasRole(userRoles, RolesConstants.ScriptWrite)
+ );
+ const canReviewMapfixes = (
+ hasRole(userRoles, RolesConstants.MapfixReview) ||
+ hasRole(userRoles, RolesConstants.MapfixUpload) ||
+ hasRole(userRoles, RolesConstants.ScriptWrite)
+ );
+
+ if (!hasAnyReviewerRole(userRoles)) {
+ return (
+
+
+
+ You do not have permission to access the reviewer dashboard. This page is only available to users with review permissions.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ }
+ aria-label="breadcrumb"
+ >
+
+ Home
+
+ Reviewer Dashboard
+
+ }
+ >
+ User Dashboard
+
+
+
+
+ Reviewer Dashboard
+
+
+
+ Manage submissions and map fixes requiring your attention.
+
+
+ {/* Summary Cards */}
+
+ {canReviewSubmissions && (
+
+
+
+
+
+
+ {isLoadingSubmissions ? (
+
+ ) : (
+ userRoles && submissions
+ ? groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0)
+ : 0
+ )}
+
+
+ Submissions Pending Review
+
+
+
+
+
+ )}
+
+ {canReviewMapfixes && (
+
+
+
+
+
+
+ {isLoadingMapfixes ? (
+
+ ) : (
+ userRoles && mapfixes
+ ? groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0)
+ : 0
+ )}
+
+
+ Map Fixes Pending Review
+
+
+
+
+
+ )}
+
+
+ {/* Tabs */}
+
+
+ {canReviewSubmissions && (
+
+ Submissions
+ {!isLoadingSubmissions && userRoles && submissions && (() => {
+ const total = groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0);
+ return total > 0 ? (
+
+ ) : null;
+ })()}
+
+ }
+ id="reviewer-tab-0"
+ aria-controls="reviewer-tabpanel-0"
+ />
+ )}
+ {canReviewMapfixes && (
+
+ Map Fixes
+ {!isLoadingMapfixes && userRoles && mapfixes && (() => {
+ const total = groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0);
+ return total > 0 ? (
+
+ ) : null;
+ })()}
+
+ }
+ id={`reviewer-tab-${canReviewSubmissions ? 1 : 0}`}
+ aria-controls={`reviewer-tabpanel-${canReviewSubmissions ? 1 : 0}`}
+ />
+ )}
+
+
+
+ {/* Submissions Tab */}
+ {canReviewSubmissions && (
+
+ {userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? (
+
+ No submissions currently need your review. Great job!
+
+ ) : isLoadingSubmissions ? (
+
+ {skeletonCards.map((i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ {userRoles && submissions && groupSubmissionsByStatus(submissions.Submissions, userRoles).map((group, groupIdx) => (
+
+
+ {group.label} ({group.items.length})
+
+
+ {group.items.map((submission) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Map Fixes Tab */}
+ {canReviewMapfixes && (
+
+ {userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).reduce((sum, group) => sum + group.items.length, 0) === 0 ? (
+
+ No map fixes currently need your review. Great job!
+
+ ) : isLoadingMapfixes ? (
+
+ {skeletonCards.map((i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ {userRoles && mapfixes && groupMapfixesByStatus(mapfixes.Mapfixes, userRoles).map((group, groupIdx) => (
+
+
+ {group.label} ({group.items.length})
+
+
+ {group.items.map((mapfix) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+
+