From 5bb123c96cc1d7f0d5799aeb3868a4596fac8495 Mon Sep 17 00:00:00 2001
From: Caleb Bae
Date: Tue, 3 Mar 2026 01:03:14 -0600
Subject: [PATCH] refactor(ui): deduplicate admin and superadmin grading flows
---
.../_shared/grading/GradingActionButtons.tsx | 96 ++++++
.../grading}/GradingDetailsPanel.tsx | 22 +-
.../_shared/grading/GradingPageLayout.tsx | 108 +++++++
.../_shared/grading/ReviewerNotesList.tsx | 50 ++++
.../src/pages/admin/_shared/grading/index.ts | 5 +
.../grading}/useGradingKeyboardShortcuts.ts | 81 +++---
.../pages/admin/all-applicants/createStore.ts | 141 +++++++++
.../src/pages/admin/all-applicants/store.ts | 126 +-------
.../src/pages/admin/assigned/AssignedPage.tsx | 6 +-
.../assigned/components/ReviewsTable.tsx | 11 +-
.../admin/assigned/components/VotingPanel.tsx | 274 ------------------
.../admin/assigned/grading/GradingPage.tsx | 184 +++++-------
.../grading/components/GradingVotingPanel.tsx | 133 +--------
.../src/pages/admin/assigned/grading/store.ts | 8 +-
.../hooks/useReviewKeyboardShortcuts.ts | 101 -------
.../pages/admin/completed/CompletedPage.tsx | 6 +-
.../components/CompletedReviewsTable.tsx | 6 +-
.../pages/superadmin/reviews/ReviewsPage.tsx | 18 +-
.../web/src/pages/superadmin/reviews/api.ts | 3 +-
.../reviews/grading/GradingPage.tsx | 185 ++++++------
.../components/GradingDetailsPanel.tsx | 86 ------
.../grading/components/GradingPanel.tsx | 133 ++-------
.../hooks/useGradingKeyboardShortcuts.ts | 74 -----
.../pages/superadmin/reviews/grading/store.ts | 15 +-
.../web/src/pages/superadmin/reviews/store.ts | 122 +-------
25 files changed, 680 insertions(+), 1314 deletions(-)
create mode 100644 client/web/src/pages/admin/_shared/grading/GradingActionButtons.tsx
rename client/web/src/pages/admin/{assigned/grading/components => _shared/grading}/GradingDetailsPanel.tsx (70%)
create mode 100644 client/web/src/pages/admin/_shared/grading/GradingPageLayout.tsx
create mode 100644 client/web/src/pages/admin/_shared/grading/ReviewerNotesList.tsx
create mode 100644 client/web/src/pages/admin/_shared/grading/index.ts
rename client/web/src/pages/admin/{assigned/grading/hooks => _shared/grading}/useGradingKeyboardShortcuts.ts (52%)
create mode 100644 client/web/src/pages/admin/all-applicants/createStore.ts
delete mode 100644 client/web/src/pages/admin/assigned/components/VotingPanel.tsx
delete mode 100644 client/web/src/pages/admin/assigned/hooks/useReviewKeyboardShortcuts.ts
delete mode 100644 client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx
delete mode 100644 client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts
diff --git a/client/web/src/pages/admin/_shared/grading/GradingActionButtons.tsx b/client/web/src/pages/admin/_shared/grading/GradingActionButtons.tsx
new file mode 100644
index 00000000..1d6e305a
--- /dev/null
+++ b/client/web/src/pages/admin/_shared/grading/GradingActionButtons.tsx
@@ -0,0 +1,96 @@
+import { Loader2, Minus, ThumbsDown, ThumbsUp } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface GradingActionButtonsProps {
+ disabled: boolean;
+ onReject: () => void;
+ onWaitlist: () => void;
+ onAccept: () => void;
+ label?: string;
+}
+
+export function GradingActionButtons({
+ disabled,
+ onReject,
+ onWaitlist,
+ onAccept,
+ label = "Cast your vote",
+}: GradingActionButtonsProps) {
+ return (
+
+
+
+
+
+
+
+ Reject (⌘J)
+
+
+
+
+
+ Waitlist (⌘K)
+
+
+
+
+
+ Accept (⌘L)
+
+
+
+ );
+}
diff --git a/client/web/src/pages/admin/assigned/grading/components/GradingDetailsPanel.tsx b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx
similarity index 70%
rename from client/web/src/pages/admin/assigned/grading/components/GradingDetailsPanel.tsx
rename to client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx
index 233579da..e29ed458 100644
--- a/client/web/src/pages/admin/assigned/grading/components/GradingDetailsPanel.tsx
+++ b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx
@@ -1,3 +1,4 @@
+import type { ReactNode } from "react";
import { memo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
@@ -13,18 +14,16 @@ import {
} from "@/pages/admin/all-applicants/components/detail-sections";
import type { Application } from "@/types";
-import type { Review } from "../../types";
-
interface GradingDetailsPanelProps {
application: Application | null;
- review: Review | null;
loading: boolean;
+ children?: ReactNode;
}
export const GradingDetailsPanel = memo(function GradingDetailsPanel({
application,
- review,
loading,
+ children,
}: GradingDetailsPanelProps) {
if (loading) {
return (
@@ -52,20 +51,7 @@ export const GradingDetailsPanel = memo(function GradingDetailsPanel({
-
- {review && (
-
-
- Review Details
-
-
- Application ID
- {review.application_id}
- Assigned at
- {new Date(review.assigned_at).toLocaleString()}
-
-
- )}
+ {children}
);
});
diff --git a/client/web/src/pages/admin/_shared/grading/GradingPageLayout.tsx b/client/web/src/pages/admin/_shared/grading/GradingPageLayout.tsx
new file mode 100644
index 00000000..4caeeaa1
--- /dev/null
+++ b/client/web/src/pages/admin/_shared/grading/GradingPageLayout.tsx
@@ -0,0 +1,108 @@
+import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
+import type { ReactNode } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+interface GradingPageLayoutProps {
+ backUrl: string;
+ loading: boolean;
+ headerContent: ReactNode;
+ currentIndex: number;
+ totalCount: number;
+ onNavigateNext: () => void;
+ onNavigatePrev: () => void;
+ canNavigatePrev: boolean;
+ canNavigateNext: boolean;
+ detailsPanel: ReactNode;
+ actionPanel: ReactNode;
+ emptyState: ReactNode;
+}
+
+export function GradingPageLayout({
+ backUrl,
+ loading,
+ headerContent,
+ currentIndex,
+ totalCount,
+ onNavigateNext,
+ onNavigatePrev,
+ canNavigatePrev,
+ canNavigateNext,
+ detailsPanel,
+ actionPanel,
+ emptyState,
+}: GradingPageLayoutProps) {
+ const navigate = useNavigate();
+
+ if (!loading && totalCount === 0) {
+ return <>{emptyState}>;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {loading ?
: headerContent}
+
+
+
+
+ {totalCount > 0 ? `${currentIndex + 1} of ${totalCount}` : "-"}
+
+
+
+
+
+ {/* Content */}
+
+ {/* Left panel - Application details (75%) */}
+
{detailsPanel}
+
+ {/* Right panel (25%) */}
+
+
{actionPanel}
+ {/* Navigation hint */}
+
+
+ Use{" "}
+
+ ←
+ {" "}
+
+ →
+ {" "}
+ arrow keys to navigate · Esc to go back
+
+
+
+
+
+ );
+}
diff --git a/client/web/src/pages/admin/_shared/grading/ReviewerNotesList.tsx b/client/web/src/pages/admin/_shared/grading/ReviewerNotesList.tsx
new file mode 100644
index 00000000..3047ffdc
--- /dev/null
+++ b/client/web/src/pages/admin/_shared/grading/ReviewerNotesList.tsx
@@ -0,0 +1,50 @@
+import { MessageSquare } from "lucide-react";
+
+import { Label } from "@/components/ui/label";
+import type { ReviewNote } from "@/pages/admin/assigned/types";
+
+interface ReviewerNotesListProps {
+ notes: ReviewNote[];
+ loading: boolean;
+}
+
+export function ReviewerNotesList({ notes, loading }: ReviewerNotesListProps) {
+ return (
+
+
+
+
+
+ {loading ? (
+
Loading notes...
+ ) : notes.length > 0 ? (
+
+ {notes.map((note, idx) => (
+
+
+
+ {note.admin_email}
+
+
+ {new Date(note.created_at).toLocaleDateString()}
+
+
+
+ {note.notes}
+
+
+ ))}
+
+ ) : (
+
+ No reviewer notes
+
+ )}
+
+ );
+}
diff --git a/client/web/src/pages/admin/_shared/grading/index.ts b/client/web/src/pages/admin/_shared/grading/index.ts
new file mode 100644
index 00000000..c403ef82
--- /dev/null
+++ b/client/web/src/pages/admin/_shared/grading/index.ts
@@ -0,0 +1,5 @@
+export { GradingActionButtons } from "./GradingActionButtons";
+export { GradingDetailsPanel } from "./GradingDetailsPanel";
+export { GradingPageLayout } from "./GradingPageLayout";
+export { ReviewerNotesList } from "./ReviewerNotesList";
+export { useGradingKeyboardShortcuts } from "./useGradingKeyboardShortcuts";
diff --git a/client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts b/client/web/src/pages/admin/_shared/grading/useGradingKeyboardShortcuts.ts
similarity index 52%
rename from client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts
rename to client/web/src/pages/admin/_shared/grading/useGradingKeyboardShortcuts.ts
index ad7bcb6c..b897f44a 100644
--- a/client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts
+++ b/client/web/src/pages/admin/_shared/grading/useGradingKeyboardShortcuts.ts
@@ -1,24 +1,26 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
-import type { ReviewVote } from "../../types";
-
interface UseGradingKeyboardShortcutsOptions {
- submitting: boolean;
- currentReviewId: string | null;
- hasVoted: boolean;
+ disabled: boolean;
+ canAct: boolean;
+ escapeUrl: string;
onNavigateNext: () => void;
onNavigatePrev: () => void;
- onVote: (vote: ReviewVote) => void;
+ onActionJ: () => void;
+ onActionK: () => void;
+ onActionL: () => void;
}
export function useGradingKeyboardShortcuts({
- submitting,
- currentReviewId,
- hasVoted,
+ disabled,
+ canAct,
+ escapeUrl,
onNavigateNext,
onNavigatePrev,
- onVote,
+ onActionJ,
+ onActionK,
+ onActionL,
}: UseGradingKeyboardShortcutsOptions) {
const navigate = useNavigate();
@@ -29,43 +31,44 @@ export function useGradingKeyboardShortcuts({
activeElement instanceof HTMLTextAreaElement ||
activeElement instanceof HTMLInputElement;
- // Escape: Go back to assigned page
+ // Skip all shortcuts when typing in an input
+ if (isInputFocused) {
+ if (e.key === "Escape") {
+ (activeElement as HTMLElement).blur();
+ }
+ return;
+ }
+
+ // Escape: Go back
if (e.key === "Escape") {
e.preventDefault();
- navigate("/admin/assigned");
+ navigate(escapeUrl);
return;
}
- // Arrow keys: Navigate between reviews (only when not typing)
- if (!isInputFocused) {
- if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
- e.preventDefault();
- onNavigatePrev();
- return;
- }
- if (e.key === "ArrowRight" || e.key === "ArrowDown") {
- e.preventDefault();
- onNavigateNext();
- return;
- }
+ // Arrow keys: Navigate between items
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
+ e.preventDefault();
+ onNavigatePrev();
+ return;
+ }
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
+ e.preventDefault();
+ onNavigateNext();
+ return;
}
- // Cmd/Ctrl + J/K/L: Vote shortcuts
- if (
- (e.metaKey || e.ctrlKey) &&
- currentReviewId &&
- !submitting &&
- !hasVoted
- ) {
+ // Cmd/Ctrl + J/K/L: Action shortcuts
+ if ((e.metaKey || e.ctrlKey) && canAct && !disabled) {
if (e.key === "j") {
e.preventDefault();
- onVote("reject");
+ onActionJ();
} else if (e.key === "k") {
e.preventDefault();
- onVote("waitlist");
+ onActionK();
} else if (e.key === "l") {
e.preventDefault();
- onVote("accept");
+ onActionL();
}
}
};
@@ -73,12 +76,14 @@ export function useGradingKeyboardShortcuts({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
- submitting,
- currentReviewId,
- hasVoted,
+ disabled,
+ canAct,
+ escapeUrl,
onNavigateNext,
onNavigatePrev,
- onVote,
+ onActionJ,
+ onActionK,
+ onActionL,
navigate,
]);
}
diff --git a/client/web/src/pages/admin/all-applicants/createStore.ts b/client/web/src/pages/admin/all-applicants/createStore.ts
new file mode 100644
index 00000000..5a319f79
--- /dev/null
+++ b/client/web/src/pages/admin/all-applicants/createStore.ts
@@ -0,0 +1,141 @@
+import { create } from "zustand";
+
+import {
+ fetchApplications as apiFetchApplications,
+ fetchApplicationStats,
+} from "./api";
+import type {
+ ApplicationListItem,
+ ApplicationSortBy,
+ ApplicationStats,
+ ApplicationStatus,
+ FetchParams,
+} from "./types";
+
+export interface ApplicationsState {
+ applications: ApplicationListItem[];
+ loading: boolean;
+ nextCursor: string | null;
+ prevCursor: string | null;
+ hasMore: boolean;
+ currentStatus: ApplicationStatus | null;
+ currentSearch: string;
+ currentSortBy?: ApplicationSortBy;
+ stats: ApplicationStats | null;
+ statsLoading: boolean;
+ fetchApplications: (
+ params?: FetchParams,
+ signal?: AbortSignal,
+ ) => Promise;
+ fetchStats: (signal?: AbortSignal) => Promise;
+ setStatusFilter: (status: ApplicationStatus | null) => void;
+ resetPagination: () => void;
+}
+
+interface ApplicationsStoreConfig {
+ defaultStatus: ApplicationStatus | null;
+ defaultSortBy?: ApplicationSortBy;
+}
+
+export function createApplicationsStore(config: ApplicationsStoreConfig) {
+ return create((set, get) => ({
+ applications: [],
+ loading: false,
+ nextCursor: null,
+ prevCursor: null,
+ hasMore: false,
+ currentStatus: config.defaultStatus,
+ currentSearch: "",
+ currentSortBy: config.defaultSortBy,
+ stats: null,
+ statsLoading: false,
+
+ fetchApplications: async (params?: FetchParams, signal?: AbortSignal) => {
+ set({ loading: true });
+
+ let status: ApplicationStatus | null;
+ if (params && "status" in params && params.status !== undefined) {
+ status = params.status;
+ } else {
+ status = get().currentStatus;
+ }
+
+ let search: string;
+ if (params && "search" in params) {
+ search = params.search ?? "";
+ } else {
+ search = get().currentSearch;
+ }
+
+ let sortBy: ApplicationSortBy | undefined;
+ if (params && "sort_by" in params && params.sort_by) {
+ sortBy = params.sort_by;
+ } else {
+ sortBy = get().currentSortBy;
+ }
+
+ const res = await apiFetchApplications(
+ {
+ ...params,
+ status,
+ search: search || undefined,
+ sort_by: sortBy,
+ },
+ signal,
+ );
+
+ if (signal?.aborted) return;
+
+ if (res.status === 200 && res.data) {
+ set({
+ applications: res.data.applications,
+ nextCursor: res.data.next_cursor,
+ prevCursor: res.data.prev_cursor,
+ hasMore: res.data.has_more,
+ loading: false,
+ currentStatus: status,
+ currentSearch: search,
+ currentSortBy: sortBy,
+ });
+ } else {
+ set({
+ applications: [],
+ nextCursor: null,
+ prevCursor: null,
+ hasMore: false,
+ loading: false,
+ });
+ }
+ },
+
+ fetchStats: async (signal?: AbortSignal) => {
+ set({ statsLoading: true });
+
+ const res = await fetchApplicationStats(signal);
+
+ if (signal?.aborted) return;
+
+ if (res.status === 200 && res.data) {
+ set({ stats: res.data, statsLoading: false });
+ } else {
+ set({ stats: null, statsLoading: false });
+ }
+ },
+
+ setStatusFilter: (status) => {
+ set({ currentStatus: status });
+ },
+
+ resetPagination: () => {
+ set({
+ applications: [],
+ nextCursor: null,
+ prevCursor: null,
+ hasMore: false,
+ currentStatus: config.defaultStatus,
+ currentSearch: "",
+ currentSortBy: config.defaultSortBy,
+ });
+ },
+ }));
+}
diff --git a/client/web/src/pages/admin/all-applicants/store.ts b/client/web/src/pages/admin/all-applicants/store.ts
index 079b5fda..fe326511 100644
--- a/client/web/src/pages/admin/all-applicants/store.ts
+++ b/client/web/src/pages/admin/all-applicants/store.ts
@@ -1,123 +1,7 @@
-import { create } from "zustand";
+import { createApplicationsStore } from "./createStore";
-import {
- fetchApplications as apiFetchApplications,
- fetchApplicationStats,
-} from "./api";
-import type {
- ApplicationListItem,
- ApplicationStats,
- ApplicationStatus,
- FetchParams,
-} from "./types";
+export type { ApplicationsState } from "./createStore";
-export interface ApplicationsState {
- applications: ApplicationListItem[];
- loading: boolean;
- nextCursor: string | null;
- prevCursor: string | null;
- hasMore: boolean;
- currentStatus: ApplicationStatus | null;
- currentSearch: string;
- stats: ApplicationStats | null;
- statsLoading: boolean;
- fetchApplications: (
- params?: FetchParams,
- signal?: AbortSignal,
- ) => Promise;
- fetchStats: (signal?: AbortSignal) => Promise;
- setStatusFilter: (status: ApplicationStatus | null) => void;
- resetPagination: () => void;
-}
-
-export const useApplicationsStore = create((set, get) => ({
- applications: [],
- loading: false,
- nextCursor: null,
- prevCursor: null,
- hasMore: false,
- currentStatus: null,
- currentSearch: "",
- stats: null,
- statsLoading: false,
-
- fetchApplications: async (params?: FetchParams, signal?: AbortSignal) => {
- set({ loading: true });
-
- // Determine status to use
- let status: ApplicationStatus | null;
- if (params && "status" in params) {
- status = params.status ?? null;
- } else {
- status = get().currentStatus;
- }
-
- // Determine search to use
- let search: string;
- if (params && "search" in params) {
- search = params.search ?? "";
- } else {
- search = get().currentSearch;
- }
-
- const res = await apiFetchApplications(
- {
- ...params,
- status,
- search: search || undefined,
- },
- signal,
- );
-
- if (signal?.aborted) return;
-
- if (res.status === 200 && res.data) {
- set({
- applications: res.data.applications,
- nextCursor: res.data.next_cursor,
- prevCursor: res.data.prev_cursor,
- hasMore: res.data.has_more,
- loading: false,
- currentStatus: status,
- currentSearch: search,
- });
- } else {
- set({
- applications: [],
- nextCursor: null,
- prevCursor: null,
- hasMore: false,
- loading: false,
- });
- }
- },
-
- fetchStats: async (signal?: AbortSignal) => {
- set({ statsLoading: true });
-
- const res = await fetchApplicationStats(signal);
-
- if (signal?.aborted) return;
-
- if (res.status === 200 && res.data) {
- set({ stats: res.data, statsLoading: false });
- } else {
- set({ stats: null, statsLoading: false });
- }
- },
-
- setStatusFilter: (status) => {
- set({ currentStatus: status });
- },
-
- resetPagination: () => {
- set({
- applications: [],
- nextCursor: null,
- prevCursor: null,
- hasMore: false,
- currentStatus: null,
- currentSearch: "",
- });
- },
-}));
+export const useApplicationsStore = createApplicationsStore({
+ defaultStatus: null,
+});
diff --git a/client/web/src/pages/admin/assigned/AssignedPage.tsx b/client/web/src/pages/admin/assigned/AssignedPage.tsx
index e4fb9dea..005596f7 100644
--- a/client/web/src/pages/admin/assigned/AssignedPage.tsx
+++ b/client/web/src/pages/admin/assigned/AssignedPage.tsx
@@ -17,16 +17,12 @@ import {
} from "@/components/ui/tooltip";
import { ApplicationDetailPanel } from "@/pages/admin/all-applicants/components/ApplicationDetailPanel";
import { useApplicationDetail } from "@/pages/admin/all-applicants/hooks/useApplicationDetail";
+import { formatName } from "@/pages/admin/all-applicants/utils";
import { ReviewsTable } from "./components/ReviewsTable";
import { refreshAssignedPage } from "./hooks/updateReviewPage";
import { useReviewsStore } from "./store";
-function formatName(firstName: string | null, lastName: string | null) {
- if (!firstName && !lastName) return "-";
- return `${firstName ?? ""} ${lastName ?? ""}`.trim();
-}
-
export default function AssignedPage() {
const navigate = useNavigate();
const { reviews, loading, fetchPendingReviews } = useReviewsStore();
diff --git a/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx b/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx
index deab3488..13d06d86 100644
--- a/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx
+++ b/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx
@@ -1,4 +1,5 @@
import { Maximize2 } from "lucide-react";
+import { memo } from "react";
import {
Table,
@@ -8,6 +9,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { formatName } from "@/pages/admin/all-applicants/utils";
import type { Review } from "../types";
import { VoteBadge } from "./VoteBadge";
@@ -19,12 +21,7 @@ interface ReviewsTableProps {
onSelectReview: (id: string) => void;
}
-function formatName(firstName: string | null, lastName: string | null) {
- if (!firstName && !lastName) return "-";
- return `${firstName ?? ""} ${lastName ?? ""}`.trim();
-}
-
-export function ReviewsTable({
+export const ReviewsTable = memo(function ReviewsTable({
reviews,
loading,
selectedId,
@@ -90,4 +87,4 @@ export function ReviewsTable({
);
-}
+});
diff --git a/client/web/src/pages/admin/assigned/components/VotingPanel.tsx b/client/web/src/pages/admin/assigned/components/VotingPanel.tsx
deleted file mode 100644
index 74897f07..00000000
--- a/client/web/src/pages/admin/assigned/components/VotingPanel.tsx
+++ /dev/null
@@ -1,274 +0,0 @@
-import {
- Check,
- MessageSquare,
- Minus,
- Pencil,
- ThumbsDown,
- ThumbsUp,
- X,
-} from "lucide-react";
-import { useState } from "react";
-import { toast } from "sonner";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-import { setAIPercent } from "../api";
-import type { Review, ReviewNote, ReviewVote } from "../types";
-import { NotesTextarea } from "./NotesTextarea";
-import { VoteBadge } from "./VoteBadge";
-
-interface VotingPanelProps {
- review: Review;
- notes: string;
- otherReviewerNotes: ReviewNote[];
- notesLoading: boolean;
- isExpanded: boolean;
- submitting: boolean;
- notesTextareaRef: React.RefObject;
- applicationId: string;
- aiPercent: number | null;
- onAiPercentUpdate: (percent: number) => void;
- onNotesChange: (id: string, notes: string) => void;
- onVote: (id: string, vote: ReviewVote) => void;
-}
-
-export function VotingPanel({
- review,
- notes,
- otherReviewerNotes,
- notesLoading,
- isExpanded,
- submitting,
- notesTextareaRef,
- applicationId,
- aiPercent,
- onAiPercentUpdate,
- onNotesChange,
- onVote,
-}: VotingPanelProps) {
- const [editing, setEditing] = useState(false);
- const [inputValue, setInputValue] = useState("");
-
- function startEditing() {
- setInputValue(aiPercent?.toString() ?? "");
- setEditing(true);
- }
-
- function cancelEditing() {
- setEditing(false);
- }
-
- async function saveEditing() {
- const trimmed = inputValue.trim();
- if (trimmed === "") {
- toast.error("AI percentage is required");
- return;
- }
- const percent = Number(trimmed);
- if (!Number.isInteger(percent) || percent < 0 || percent > 100) {
- toast.error("AI percent must be a whole number between 0 and 100");
- return;
- }
-
- const result = await setAIPercent(applicationId, { ai_percent: percent });
- if (result.success) {
- onAiPercentUpdate(percent);
- toast.success("AI percent saved");
- } else {
- toast.error(result.error ?? "Failed to set AI percent");
- }
- setEditing(false);
- }
- return (
-
- {/* Other Reviewers' Notes */}
- {otherReviewerNotes.length > 0 && (
-
-
-
-
-
-
- {otherReviewerNotes.map((note, idx) => (
-
-
-
- {note.admin_email}
-
-
- {new Date(note.created_at).toLocaleDateString()}
-
-
-
{note.notes}
-
- ))}
-
-
- )}
- {notesLoading && otherReviewerNotes.length === 0 && (
-
Loading notes...
- )}
-
- {/* Your Notes */}
-
-
-
- {!review.vote && (
-
- Write notes before casting your vote
-
- )}
-
-
-
-
- {/* AI Percent */}
-
-
- {editing ? (
-
- setInputValue(e.target.value)}
- className="h-7 w-24 text-sm"
- autoFocus
- />
-
-
-
- ) : aiPercent != null ? (
-
{aiPercent}%
- ) : (
-
- )}
-
-
- {review.vote ? (
-
-
- You voted:
-
- {review.reviewed_at && (
-
- {new Date(review.reviewed_at).toLocaleString()}
-
- )}
-
- ) : (
- <>
-
Cast your vote
-
-
-
-
-
- Reject (⌘J)
-
-
-
-
-
- Waitlist (⌘K)
-
-
-
-
-
- Accept (⌘L)
-
-
- {submitting && (
-
- Submitting vote...
-
- )}
- >
- )}
-
- );
-}
diff --git a/client/web/src/pages/admin/assigned/grading/GradingPage.tsx b/client/web/src/pages/admin/assigned/grading/GradingPage.tsx
index 73040074..0f08e31e 100644
--- a/client/web/src/pages/admin/assigned/grading/GradingPage.tsx
+++ b/client/web/src/pages/admin/assigned/grading/GradingPage.tsx
@@ -1,22 +1,20 @@
-import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
+import { ArrowLeft } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
+import {
+ GradingDetailsPanel,
+ GradingPageLayout,
+ useGradingKeyboardShortcuts,
+} from "@/pages/admin/_shared/grading";
+import { formatName } from "@/pages/admin/all-applicants/utils";
import { VoteBadge } from "../components/VoteBadge";
import type { ReviewVote } from "../types";
-import { GradingDetailsPanel } from "./components/GradingDetailsPanel";
import { GradingVotingPanel } from "./components/GradingVotingPanel";
-import { useGradingKeyboardShortcuts } from "./hooks/useGradingKeyboardShortcuts";
import { useAdminGradingStore } from "./store";
-function formatName(firstName: string | null, lastName: string | null) {
- if (!firstName && !lastName) return "-";
- return `${firstName ?? ""} ${lastName ?? ""}`.trim();
-}
-
export default function GradingPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -76,125 +74,85 @@ export default function GradingPage() {
);
useGradingKeyboardShortcuts({
- submitting,
- currentReviewId: currentReview?.id ?? null,
- hasVoted: !!currentReview?.vote,
+ disabled: submitting,
+ canAct: !!currentReview?.id && !currentReview?.vote,
+ escapeUrl: "/admin/assigned",
onNavigateNext: navigateNext,
onNavigatePrev: navigatePrev,
- onVote: handleVote,
+ onActionJ: () => handleVote("reject"),
+ onActionK: () => handleVote("waitlist"),
+ onActionL: () => handleVote("accept"),
});
- // Empty state
- if (!loading && reviews.length === 0) {
- return (
-
-
No pending reviews to grade.
-
-
- );
- }
-
return (
-
- {/* Header */}
-
-
-
- {loading ? (
-
- ) : currentReview ? (
+
{formatName(currentReview.first_name, currentReview.last_name)}
>
- ) : null}
-
-
-
-
- {reviews.length > 0
- ? `${currentIndex + 1} of ${reviews.length}`
- : "-"}
-
+ ) : null
+ }
+ currentIndex={currentIndex}
+ totalCount={reviews.length}
+ onNavigateNext={navigateNext}
+ onNavigatePrev={navigatePrev}
+ canNavigatePrev={!loading && currentIndex > 0}
+ canNavigateNext={!loading && currentIndex < reviews.length - 1}
+ detailsPanel={
+
+ {currentReview && (
+
+
+ Review Details
+
+
+ Application ID
+
+ {currentReview.application_id}
+
+ Assigned at
+
+ {new Date(currentReview.assigned_at).toLocaleString()}
+
+
+
+ )}
+
+ }
+ actionPanel={
+ currentReview ? (
+
+ ) : null
+ }
+ emptyState={
+
+
No pending reviews to grade.
-
-
- {/* Content */}
-
- {/* Left panel - Application details (75%) */}
-
-
-
-
- {/* Right panel - Voting (25%) */}
-
-
- {currentReview && (
-
- )}
-
- {/* Navigation hint */}
-
-
- Use{" "}
-
- ←
- {" "}
-
- →
- {" "}
- arrow keys to navigate · Esc to go back
-
-
-
-
-
+ }
+ />
);
}
diff --git a/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx b/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx
index 71ccfc5e..5adb3a48 100644
--- a/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx
+++ b/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx
@@ -1,13 +1,4 @@
-import {
- Check,
- Loader2,
- MessageSquare,
- Minus,
- Pencil,
- ThumbsDown,
- ThumbsUp,
- X,
-} from "lucide-react";
+import { Check, Pencil, X } from "lucide-react";
import { memo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -15,10 +6,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+ GradingActionButtons,
+ ReviewerNotesList,
+} from "@/pages/admin/_shared/grading";
import { setAIPercent } from "../../api";
import { NotesTextarea } from "../../components/NotesTextarea";
@@ -88,42 +78,7 @@ export const GradingVotingPanel = memo(function GradingVotingPanel({
return (
{/* Other Reviewers' Notes */}
-
-
-
-
-
- {notesLoading ? (
-
Loading notes...
- ) : otherReviewerNotes.length > 0 ? (
-
- {otherReviewerNotes.map((note, idx) => (
-
-
-
- {note.admin_email}
-
-
- {new Date(note.created_at).toLocaleDateString()}
-
-
-
- {note.notes}
-
-
- ))}
-
- ) : (
-
- No reviewer notes
-
- )}
-
+
{/* Your Notes */}
@@ -216,81 +171,19 @@ export const GradingVotingPanel = memo(function GradingVotingPanel({
)}
) : (
-
-
-
-
-
-
-
- Reject (⌘J)
-
-
-
-
-
- Waitlist (⌘K)
-
-
-
-
-
- Accept (⌘L)
-
-
+ <>
+
onVote("reject")}
+ onWaitlist={() => onVote("waitlist")}
+ onAccept={() => onVote("accept")}
+ />
{submitting && (
Submitting vote...
)}
-
+ >
)}
);
diff --git a/client/web/src/pages/admin/assigned/grading/store.ts b/client/web/src/pages/admin/assigned/grading/store.ts
index 3d194401..d613eadc 100644
--- a/client/web/src/pages/admin/assigned/grading/store.ts
+++ b/client/web/src/pages/admin/assigned/grading/store.ts
@@ -21,7 +21,6 @@ interface GradingState {
notesLoading: boolean;
submitting: boolean;
localNotes: string;
-
fetchReviews: () => Promise
;
loadDetail: (applicationId: string) => Promise;
navigateNext: () => void;
@@ -43,6 +42,8 @@ const initialState = {
localNotes: "",
};
+let loadDetailSeq = 0;
+
export const useAdminGradingStore = create((set, get) => ({
...initialState,
@@ -58,6 +59,7 @@ export const useAdminGradingStore = create((set, get) => ({
},
loadDetail: async (applicationId: string) => {
+ const requestId = ++loadDetailSeq;
set({
detailLoading: true,
notesLoading: true,
@@ -71,6 +73,9 @@ export const useAdminGradingStore = create((set, get) => ({
fetchReviewNotes(applicationId),
]);
+ // Guard against stale responses from rapid navigation
+ if (loadDetailSeq !== requestId) return;
+
if (detailRes.status === 200 && detailRes.data) {
set({ detail: detailRes.data, detailLoading: false });
} else {
@@ -141,6 +146,7 @@ export const useAdminGradingStore = create((set, get) => ({
},
reset: () => {
+ loadDetailSeq = 0;
set(initialState);
},
}));
diff --git a/client/web/src/pages/admin/assigned/hooks/useReviewKeyboardShortcuts.ts b/client/web/src/pages/admin/assigned/hooks/useReviewKeyboardShortcuts.ts
deleted file mode 100644
index c69a7513..00000000
--- a/client/web/src/pages/admin/assigned/hooks/useReviewKeyboardShortcuts.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useEffect } from "react";
-
-import type { Review, ReviewVote } from "../types";
-
-interface UseReviewKeyboardShortcutsOptions {
- isExpanded: boolean;
- selectedId: string | null;
- reviews: Review[];
- submitting: boolean;
- notesTextareaRef: React.RefObject;
- onVote: (id: string, vote: ReviewVote) => void;
- onNavigate: (id: string) => void;
- onCloseExpanded: () => void;
-}
-
-export function useReviewKeyboardShortcuts({
- isExpanded,
- selectedId,
- reviews,
- submitting,
- notesTextareaRef,
- onVote,
- onNavigate,
- onCloseExpanded,
-}: UseReviewKeyboardShortcutsOptions) {
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- const activeElement = document.activeElement;
- const isInputFocused =
- activeElement instanceof HTMLTextAreaElement ||
- activeElement instanceof HTMLInputElement;
-
- // Escape: Close expanded view
- if (e.key === "Escape" && isExpanded) {
- e.preventDefault();
- onCloseExpanded();
- return;
- }
-
- // Tab: Focus notes textarea in expanded view
- if (e.key === "Tab" && isExpanded && selectedId && !isInputFocused) {
- e.preventDefault();
- notesTextareaRef.current?.focus();
- return;
- }
-
- // Arrow keys: Navigate between reviews (only when not typing)
- if (!isInputFocused && reviews.length > 0) {
- const currentIndex = selectedId
- ? reviews.findIndex((r) => r.id === selectedId)
- : -1;
-
- if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
- e.preventDefault();
- if (currentIndex > 0) {
- onNavigate(reviews[currentIndex - 1].id);
- } else if (currentIndex === -1) {
- onNavigate(reviews[reviews.length - 1].id);
- }
- } else if (e.key === "ArrowDown" || e.key === "ArrowRight") {
- e.preventDefault();
- if (currentIndex === -1) {
- onNavigate(reviews[0].id);
- } else if (currentIndex < reviews.length - 1) {
- onNavigate(reviews[currentIndex + 1].id);
- }
- }
- }
-
- // Cmd/Ctrl + J/K/L: Vote shortcuts (only in expanded view with selected review)
- if ((e.metaKey || e.ctrlKey) && isExpanded && selectedId) {
- const selectedReview = reviews.find((r) => r.id === selectedId);
- // Don't allow voting if already voted or submitting
- if (selectedReview?.vote || submitting) return;
-
- if (e.key === "j") {
- e.preventDefault();
- onVote(selectedId, "reject");
- } else if (e.key === "k") {
- e.preventDefault();
- onVote(selectedId, "waitlist");
- } else if (e.key === "l") {
- e.preventDefault();
- onVote(selectedId, "accept");
- }
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [
- isExpanded,
- selectedId,
- reviews,
- submitting,
- notesTextareaRef,
- onVote,
- onNavigate,
- onCloseExpanded,
- ]);
-}
diff --git a/client/web/src/pages/admin/completed/CompletedPage.tsx b/client/web/src/pages/admin/completed/CompletedPage.tsx
index 1c8d7bd2..2b00317d 100644
--- a/client/web/src/pages/admin/completed/CompletedPage.tsx
+++ b/client/web/src/pages/admin/completed/CompletedPage.tsx
@@ -9,6 +9,7 @@ import {
CardHeader,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
+import { formatName } from "@/pages/admin/all-applicants/utils";
import { errorAlert, getRequest } from "@/shared/lib/api";
import type { Application } from "@/types";
@@ -18,11 +19,6 @@ import { CompletedReviewsTable } from "./components/CompletedReviewsTable";
import { useCompletedReviewsStore } from "./store";
import type { NotesListResponse, ReviewNote } from "./types";
-function formatName(firstName: string | null, lastName: string | null) {
- if (!firstName && !lastName) return "-";
- return `${firstName ?? ""} ${lastName ?? ""}`.trim();
-}
-
export default function CompletedPage() {
const { reviews, loading, fetchCompletedReviews } =
useCompletedReviewsStore();
diff --git a/client/web/src/pages/admin/completed/components/CompletedReviewsTable.tsx b/client/web/src/pages/admin/completed/components/CompletedReviewsTable.tsx
index 7ccd6893..c1760cd7 100644
--- a/client/web/src/pages/admin/completed/components/CompletedReviewsTable.tsx
+++ b/client/web/src/pages/admin/completed/components/CompletedReviewsTable.tsx
@@ -9,6 +9,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { formatName } from "@/pages/admin/all-applicants/utils";
import { VoteBadge } from "../../assigned/components/VoteBadge";
import type { Review } from "../types";
@@ -20,11 +21,6 @@ interface CompletedReviewsTableProps {
onSelectReview: (id: string) => void;
}
-function formatName(firstName: string | null, lastName: string | null) {
- if (!firstName && !lastName) return "-";
- return `${firstName ?? ""} ${lastName ?? ""}`.trim();
-}
-
export function CompletedReviewsTable({
reviews,
selectedId,
diff --git a/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx b/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx
index a8013897..d43a8cf5 100644
--- a/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx
+++ b/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx
@@ -89,7 +89,9 @@ export default function ReviewsPage() {
);
const fetchStats = useReviewApplicationsStore((s) => s.fetchStats);
- const [emailStatus, setEmailStatus] = useState(null);
+ const [emailStatus, setEmailStatus] = useState(
+ null,
+ );
const [downloadingCsv, setDownloadingCsv] = useState(false);
const [searchInput, setSearchInput] = useState(currentSearch);
const [selectedApplicationId, setSelectedApplicationId] = useState<
@@ -388,7 +390,7 @@ export default function ReviewsPage() {
@@ -421,8 +423,8 @@ export default function ReviewsPage() {
{applications.length} application(s) on this page
filtered by
-
- {currentStatus}
+
+ {currentStatus ?? "submitted"}
{currentSearch && matching "{currentSearch}"}
@@ -430,7 +432,7 @@ export default function ReviewsPage() {
{currentSortBy === "accept_votes"
? "accept votes"
: currentSortBy === "reject_votes"
- ? "reject votess"
+ ? "reject votes"
: currentSortBy === "waitlist_votes"
? "waitlist votes"
: "date created"}
@@ -473,7 +475,9 @@ export default function ReviewsPage() {
setEmailStatus(value)}
+ onValueChange={(value) =>
+ setEmailStatus(value as ApplicationStatus)
+ }
className="gap-2"
>
{(
@@ -538,7 +542,7 @@ export default function ReviewsPage() {
loading={tableLoading}
selectedId={selectedApplicationId}
onSelectApplication={setSelectedApplicationId}
- sortBy={currentSortBy}
+ sortBy={currentSortBy ?? "accept_votes"}
onSortChange={handleSortChange}
/>
diff --git a/client/web/src/pages/superadmin/reviews/api.ts b/client/web/src/pages/superadmin/reviews/api.ts
index 53976c44..b7ddca85 100644
--- a/client/web/src/pages/superadmin/reviews/api.ts
+++ b/client/web/src/pages/superadmin/reviews/api.ts
@@ -1,3 +1,4 @@
+import type { ApplicationStatus } from "@/pages/admin/all-applicants/types";
import { getRequest } from "@/shared/lib/api";
interface ApplicantEmail {
@@ -11,7 +12,7 @@ interface EmailListResponse {
count: number;
}
-export async function fetchApplicantEmails(status: string) {
+export async function fetchApplicantEmails(status: ApplicationStatus) {
return getRequest(
`/superadmin/applications/emails?status=${status}`,
"applicant emails",
diff --git a/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx b/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx
index 4c8713cc..d02310f6 100644
--- a/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx
+++ b/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx
@@ -1,19 +1,21 @@
-import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
+import { ArrowLeft } from "lucide-react";
import { useCallback, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Skeleton } from "@/components/ui/skeleton";
+import {
+ GradingDetailsPanel,
+ GradingPageLayout,
+ useGradingKeyboardShortcuts,
+} from "@/pages/admin/_shared/grading";
import type {
ApplicationSortBy,
ApplicationStatus,
} from "@/pages/admin/all-applicants/types";
import { formatName, getStatusColor } from "@/pages/admin/all-applicants/utils";
-import { GradingDetailsPanel } from "./components/GradingDetailsPanel";
import { GradingPanel } from "./components/GradingPanel";
-import { useGradingKeyboardShortcuts } from "./hooks/useGradingKeyboardShortcuts";
import { useGradingStore } from "./store";
export default function GradingPage() {
@@ -28,6 +30,8 @@ export default function GradingPage() {
const notes = useGradingStore((s) => s.notes);
const notesLoading = useGradingStore((s) => s.notesLoading);
const grading = useGradingStore((s) => s.grading);
+ const nextCursor = useGradingStore((s) => s.nextCursor);
+ const prevCursor = useGradingStore((s) => s.prevCursor);
const fetchApplications = useGradingStore((s) => s.fetchApplications);
const loadDetail = useGradingStore((s) => s.loadDetail);
const navigateNext = useGradingStore((s) => s.navigateNext);
@@ -79,48 +83,22 @@ export default function GradingPage() {
);
useGradingKeyboardShortcuts({
- grading,
- currentApplicationId: currentApp?.id ?? null,
+ disabled: grading,
+ canAct: !!currentApp?.id,
+ escapeUrl: "/admin/sa/reviews",
onNavigateNext: navigateNext,
onNavigatePrev: navigatePrev,
- onGrade: handleGrade,
+ onActionJ: () => handleGrade("rejected"),
+ onActionK: () => handleGrade("waitlisted"),
+ onActionL: () => handleGrade("accepted"),
});
- // Empty state
- if (!loading && applications.length === 0) {
- return (
-
-
- No applications match the current filters.
-
-
-
- );
- }
-
return (
-
- {/* Header */}
-
-
-
- {loading ? (
-
- ) : currentApp ? (
+
{formatName(currentApp.first_name, currentApp.last_name)}
@@ -129,72 +107,73 @@ export default function GradingPage() {
{currentApp.status}
>
- ) : null}
-
-
-
-
- {applications.length > 0
- ? `${currentIndex + 1} of ${applications.length}`
- : "-"}
-
+ ) : null
+ }
+ currentIndex={currentIndex}
+ totalCount={applications.length}
+ onNavigateNext={navigateNext}
+ onNavigatePrev={navigatePrev}
+ canNavigatePrev={!loading && (currentIndex > 0 || !!prevCursor)}
+ canNavigateNext={
+ !loading && (currentIndex < applications.length - 1 || !!nextCursor)
+ }
+ detailsPanel={
+
+ {currentApp && (
+
+
+ Review Stats
+
+
+
+ {currentApp.reviews_completed} / {currentApp.reviews_assigned}{" "}
+ reviews completed
+
+
+
+ {currentApp.accept_votes} accept
+
+
+ {currentApp.reject_votes} reject
+
+
+ {currentApp.waitlist_votes} waitlist
+
+ {currentApp.ai_percent != null && (
+
+ AI: {currentApp.ai_percent}%
+
+ )}
+
+
+
+ )}
+
+ }
+ actionPanel={
+
+ }
+ emptyState={
+
+
+ No applications match the current filters.
+
-
-
- {/* Content */}
-
- {/* Left panel - Application details (75%) */}
-
-
-
-
- {/* Right panel - Grading (25%) */}
-
-
-
-
- {/* Navigation hint - pinned at bottom */}
-
-
- Use{" "}
-
- ←
- {" "}
-
- →
- {" "}
- arrow keys to navigate · Esc to go back
-
-
-
-
-
+ }
+ />
);
}
diff --git a/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx b/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx
deleted file mode 100644
index 42043e56..00000000
--- a/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { memo } from "react";
-
-import { Badge } from "@/components/ui/badge";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- DemographicsSection,
- EducationSection,
- EventPreferencesSection,
- ExperienceSection,
- LinksSection,
- PersonalInfoSection,
- ShortAnswersSection,
- TimelineSection,
-} from "@/pages/admin/all-applicants/components/detail-sections";
-import type { ApplicationListItem } from "@/pages/admin/all-applicants/types";
-import type { Application } from "@/types";
-
-interface GradingDetailsPanelProps {
- application: Application | null;
- listItem: ApplicationListItem | null;
- loading: boolean;
-}
-
-export const GradingDetailsPanel = memo(function GradingDetailsPanel({
- application,
- listItem,
- loading,
-}: GradingDetailsPanelProps) {
- if (loading) {
- return (
-
- {[...Array(4)].map((_, i) => (
-
-
-
-
-
- ))}
-
- );
- }
-
- if (!application) return null;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {/* Review Stats */}
- {listItem && (
-
-
- Review Stats
-
-
-
- {listItem.reviews_completed} / {listItem.reviews_assigned} reviews
- completed
-
-
-
- {listItem.accept_votes} accept
-
-
- {listItem.reject_votes} reject
-
-
- {listItem.waitlist_votes} waitlist
-
- {listItem.ai_percent != null && (
- AI: {listItem.ai_percent}%
- )}
-
-
-
- )}
-
- );
-});
diff --git a/client/web/src/pages/superadmin/reviews/grading/components/GradingPanel.tsx b/client/web/src/pages/superadmin/reviews/grading/components/GradingPanel.tsx
index 7bb1f6c9..d2eee5f7 100644
--- a/client/web/src/pages/superadmin/reviews/grading/components/GradingPanel.tsx
+++ b/client/web/src/pages/superadmin/reviews/grading/components/GradingPanel.tsx
@@ -1,20 +1,12 @@
-import {
- Loader2,
- MessageSquare,
- Minus,
- ThumbsDown,
- ThumbsUp,
-} from "lucide-react";
+import { Minus, ThumbsDown, ThumbsUp } from "lucide-react";
import { memo } from "react";
import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+ GradingActionButtons,
+ ReviewerNotesList,
+} from "@/pages/admin/_shared/grading";
import type { ApplicationListItem } from "@/pages/admin/all-applicants/types";
import { getStatusColor } from "@/pages/admin/all-applicants/utils";
import type { ReviewNote } from "@/pages/admin/assigned/types";
@@ -68,116 +60,25 @@ export const GradingPanel = memo(function GradingPanel({
{listItem.waitlist_votes}
+ {listItem.ai_percent != null && (
+
+ AI: {listItem.ai_percent}%
+
+ )}
{/* Reviewer Notes */}
-
-
-
-
-
- {notesLoading ? (
-
Loading notes...
- ) : notes.length > 0 ? (
-
- {notes.map((note, idx) => (
-
-
-
- {note.admin_email}
-
-
- {new Date(note.created_at).toLocaleDateString()}
-
-
-
- {note.notes}
-
-
- ))}
-
- ) : (
-
- No reviewer notes
-
- )}
-
+
{/* Grade Applicant */}
-
-
-
-
-
-
-
- Reject (⌘J)
-
-
-
-
-
- Waitlist (⌘K)
-
-
-
-
-
- Accept (⌘L)
-
-
-
+ onGrade("rejected")}
+ onWaitlist={() => onGrade("waitlisted")}
+ onAccept={() => onGrade("accepted")}
+ label="Grade Applicant"
+ />
);
});
diff --git a/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts b/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts
deleted file mode 100644
index ac4b1eca..00000000
--- a/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useEffect } from "react";
-import { useNavigate } from "react-router-dom";
-
-interface UseGradingKeyboardShortcutsOptions {
- grading: boolean;
- currentApplicationId: string | null;
- onNavigateNext: () => void;
- onNavigatePrev: () => void;
- onGrade: (status: "accepted" | "rejected" | "waitlisted") => void;
-}
-
-export function useGradingKeyboardShortcuts({
- grading,
- currentApplicationId,
- onNavigateNext,
- onNavigatePrev,
- onGrade,
-}: UseGradingKeyboardShortcutsOptions) {
- const navigate = useNavigate();
-
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- const activeElement = document.activeElement;
- const isInputFocused =
- activeElement instanceof HTMLTextAreaElement ||
- activeElement instanceof HTMLInputElement;
-
- // Escape: Go back to reviews page
- if (e.key === "Escape") {
- e.preventDefault();
- navigate("/admin/sa/reviews");
- return;
- }
-
- // Arrow keys: Navigate between applications (only when not typing)
- if (!isInputFocused) {
- if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
- e.preventDefault();
- onNavigatePrev();
- return;
- }
- if (e.key === "ArrowRight" || e.key === "ArrowDown") {
- e.preventDefault();
- onNavigateNext();
- return;
- }
- }
-
- // Cmd/Ctrl + J/K/L: Grade shortcuts
- if ((e.metaKey || e.ctrlKey) && currentApplicationId && !grading) {
- if (e.key === "j") {
- e.preventDefault();
- onGrade("rejected");
- } else if (e.key === "k") {
- e.preventDefault();
- onGrade("waitlisted");
- } else if (e.key === "l") {
- e.preventDefault();
- onGrade("accepted");
- }
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [
- grading,
- currentApplicationId,
- onNavigateNext,
- onNavigatePrev,
- onGrade,
- navigate,
- ]);
-}
diff --git a/client/web/src/pages/superadmin/reviews/grading/store.ts b/client/web/src/pages/superadmin/reviews/grading/store.ts
index 7f11cb99..10222bb7 100644
--- a/client/web/src/pages/superadmin/reviews/grading/store.ts
+++ b/client/web/src/pages/superadmin/reviews/grading/store.ts
@@ -35,7 +35,6 @@ interface GradingState {
nextCursor: string | null;
prevCursor: string | null;
filterParams: FilterParams;
-
fetchApplications: (params?: FetchParams) => Promise;
loadDetail: (applicationId: string) => Promise;
navigateNext: () => void;
@@ -61,6 +60,8 @@ const initialState = {
filterParams: {} as FilterParams,
};
+let loadDetailSeq = 0;
+
export const useGradingStore = create((set, get) => ({
...initialState,
@@ -100,13 +101,22 @@ export const useGradingStore = create((set, get) => ({
},
loadDetail: async (applicationId: string) => {
- set({ detailLoading: true, notesLoading: true, detail: null, notes: [] });
+ const requestId = ++loadDetailSeq;
+ set({
+ detailLoading: true,
+ notesLoading: true,
+ detail: null,
+ notes: [],
+ });
const [detailRes, notesRes] = await Promise.all([
fetchApplicationById(applicationId),
fetchReviewNotes(applicationId),
]);
+ // Guard against stale responses from rapid navigation
+ if (loadDetailSeq !== requestId) return;
+
if (detailRes.status === 200 && detailRes.data) {
set({ detail: detailRes.data, detailLoading: false });
} else {
@@ -193,6 +203,7 @@ export const useGradingStore = create((set, get) => ({
},
reset: () => {
+ loadDetailSeq = 0;
set(initialState);
},
}));
diff --git a/client/web/src/pages/superadmin/reviews/store.ts b/client/web/src/pages/superadmin/reviews/store.ts
index 019c3681..f056f98f 100644
--- a/client/web/src/pages/superadmin/reviews/store.ts
+++ b/client/web/src/pages/superadmin/reviews/store.ts
@@ -1,118 +1,6 @@
-import { create } from "zustand";
+import { createApplicationsStore } from "@/pages/admin/all-applicants/createStore";
-import {
- fetchApplications as apiFetchApplications,
- fetchApplicationStats,
-} from "@/pages/admin/all-applicants/api";
-import type {
- ApplicationListItem,
- ApplicationSortBy,
- ApplicationStats,
- ApplicationStatus,
- FetchParams,
-} from "@/pages/admin/all-applicants/types";
-
-export interface ReviewApplicationsState {
- applications: ApplicationListItem[];
- loading: boolean;
- nextCursor: string | null;
- prevCursor: string | null;
- hasMore: boolean;
- currentStatus: ApplicationStatus;
- currentSearch: string;
- currentSortBy: ApplicationSortBy;
- stats: ApplicationStats | null;
- statsLoading: boolean;
- fetchApplications: (
- params?: FetchParams,
- signal?: AbortSignal,
- ) => Promise;
- fetchStats: (signal?: AbortSignal) => Promise;
-}
-
-export const useReviewApplicationsStore = create(
- (set, get) => ({
- applications: [],
- loading: false,
- nextCursor: null,
- prevCursor: null,
- hasMore: false,
- currentStatus: "submitted",
- currentSearch: "",
- currentSortBy: "accept_votes",
- stats: null,
- statsLoading: false,
-
- fetchApplications: async (params?: FetchParams, signal?: AbortSignal) => {
- set({ loading: true });
-
- let status: ApplicationStatus;
- if (params && "status" in params && params.status) {
- status = params.status;
- } else {
- status = get().currentStatus;
- }
-
- let search: string;
- if (params && "search" in params) {
- search = params.search ?? "";
- } else {
- search = get().currentSearch;
- }
-
- let sortBy: ApplicationSortBy;
- if (params && "sort_by" in params && params.sort_by) {
- sortBy = params.sort_by;
- } else {
- sortBy = get().currentSortBy;
- }
-
- const res = await apiFetchApplications(
- {
- ...params,
- status,
- search: search || undefined,
- sort_by: sortBy,
- },
- signal,
- );
-
- if (signal?.aborted) return;
-
- if (res.status === 200 && res.data) {
- set({
- applications: res.data.applications,
- nextCursor: res.data.next_cursor,
- prevCursor: res.data.prev_cursor,
- hasMore: res.data.has_more,
- loading: false,
- currentStatus: status,
- currentSearch: search,
- currentSortBy: sortBy,
- });
- } else {
- set({
- applications: [],
- nextCursor: null,
- prevCursor: null,
- hasMore: false,
- loading: false,
- });
- }
- },
-
- fetchStats: async (signal?: AbortSignal) => {
- set({ statsLoading: true });
-
- const res = await fetchApplicationStats(signal);
-
- if (signal?.aborted) return;
-
- if (res.status === 200 && res.data) {
- set({ stats: res.data, statsLoading: false });
- } else {
- set({ stats: null, statsLoading: false });
- }
- },
- }),
-);
+export const useReviewApplicationsStore = createApplicationsStore({
+ defaultStatus: "submitted",
+ defaultSortBy: "accept_votes",
+});