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}%

- ) : ( -
-

Not set

- -
- )} -
- - {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", +});