diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx index 7ef3b3a2..01ba1305 100644 --- a/client/web/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx @@ -5,7 +5,6 @@ import { CircleCheck, CircleHelp, ClipboardList, - Mail, ScanLine, Settings, Star, @@ -67,6 +66,11 @@ const eventNav = [ ]; const superAdminNav = [ + { + name: "Reviews", + url: "/admin/sa/reviews", + icon: Star, + }, { name: "User Management", url: "/admin/sa/user-management", @@ -77,16 +81,6 @@ const superAdminNav = [ url: "/admin/sa/application", icon: ClipboardList, }, - { - name: "Reviews", - url: "/admin/sa/reviews", - icon: Star, - }, - { - name: "Emails", - url: "/admin/sa/emails", - icon: Mail, - }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx index d675f6b3..03e323f5 100644 --- a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx +++ b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx @@ -59,11 +59,10 @@ export default function AllApplicantsPage() { const timer = setTimeout(() => { fetchApplications({ search: searchInput.length >= 2 ? searchInput : "", - status: currentStatus, }); }, 500); return () => clearTimeout(timer); - }, [searchInput, fetchApplications, currentStatus]); + }, [searchInput, fetchApplications]); const handleClosePanel = useCallback(() => { setSelectedApplicationId(null); @@ -152,6 +151,7 @@ export default function AllApplicantsPage() { {currentSearch && matching "{currentSearch}"} +
void; + onGrade?: () => void; } export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ application, loading, onClose, + onGrade, }: ApplicationDetailPanelProps) { return ( - +
{loading ? ( @@ -47,14 +54,31 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ ) : null}
- +
+ {onGrade && ( + + + + + Grade applicant + + )} + +
{loading ? ( diff --git a/client/web/src/pages/admin/all-applicants/components/StatusFilterTabs.tsx b/client/web/src/pages/admin/all-applicants/components/StatusFilterTabs.tsx index 2e941675..29525421 100644 --- a/client/web/src/pages/admin/all-applicants/components/StatusFilterTabs.tsx +++ b/client/web/src/pages/admin/all-applicants/components/StatusFilterTabs.tsx @@ -33,7 +33,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ All {stats && ( @@ -45,7 +45,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ Draft {stats && stats.draft > 0 && ( @@ -57,7 +57,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ Submitted {stats && stats.submitted > 0 && ( @@ -69,7 +69,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ Accepted {stats && stats.accepted > 0 && ( @@ -81,7 +81,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ Waitlisted {stats && stats.waitlisted > 0 && ( @@ -93,7 +93,7 @@ export const StatusFilterTabs = memo(function StatusFilterTabs({ Rejected {stats && stats.rejected > 0 && ( diff --git a/client/web/src/pages/admin/all-applicants/types.ts b/client/web/src/pages/admin/all-applicants/types.ts index d00686df..883aab45 100644 --- a/client/web/src/pages/admin/all-applicants/types.ts +++ b/client/web/src/pages/admin/all-applicants/types.ts @@ -24,6 +24,11 @@ export interface ApplicationListItem { created_at: string; updated_at: string; ai_percent: number | null; + accept_votes: number; + reject_votes: number; + waitlist_votes: number; + reviews_assigned: number; + reviews_completed: number; } export interface ApplicationListResult { @@ -43,9 +48,16 @@ export interface ApplicationStats { acceptance_rate: number; } +export type ApplicationSortBy = + | "created_at" + | "accept_votes" + | "reject_votes" + | "waitlist_votes"; + export interface FetchParams { cursor?: string; status?: ApplicationStatus | null; direction?: "forward" | "backward"; search?: string; + sort_by?: ApplicationSortBy; } diff --git a/client/web/src/pages/admin/assigned/AssignedPage.tsx b/client/web/src/pages/admin/assigned/AssignedPage.tsx index bbd9bfae..e4fb9dea 100644 --- a/client/web/src/pages/admin/assigned/AssignedPage.tsx +++ b/client/web/src/pages/admin/assigned/AssignedPage.tsx @@ -1,5 +1,6 @@ -import { ClipboardPen, Minimize2, X } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { ClipboardPen } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { @@ -14,17 +15,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { errorAlert, getRequest } from "@/shared/lib/api"; -import type { Application } from "@/types"; +import { ApplicationDetailPanel } from "@/pages/admin/all-applicants/components/ApplicationDetailPanel"; +import { useApplicationDetail } from "@/pages/admin/all-applicants/hooks/useApplicationDetail"; -import { ApplicationDetailsPanel } from "./components/ApplicationDetailsPanel"; import { ReviewsTable } from "./components/ReviewsTable"; -import { VoteBadge } from "./components/VoteBadge"; -import { VotingPanel } from "./components/VotingPanel"; import { refreshAssignedPage } from "./hooks/updateReviewPage"; -import { useReviewKeyboardShortcuts } from "./hooks/useReviewKeyboardShortcuts"; import { useReviewsStore } from "./store"; -import type { NotesListResponse, ReviewNote, ReviewVote } from "./types"; function formatName(firstName: string | null, lastName: string | null) { if (!firstName && !lastName) return "-"; @@ -32,31 +28,21 @@ function formatName(firstName: string | null, lastName: string | null) { } export default function AssignedPage() { - const { reviews, loading, submitting, fetchPendingReviews, submitVote } = - useReviewsStore(); - const [selectedId, setSelectedId] = useState(null); - const [applicationDetail, setApplicationDetail] = - useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [otherReviewerNotes, setOtherReviewerNotes] = useState( - [], - ); - const [notesLoading, setNotesLoading] = useState(false); - const [localVotes, setLocalVotes] = useState< - Record - >({}); - const [localNotes, setLocalNotes] = useState>({}); - const [isExpanded, setIsExpanded] = useState(false); - const notesTextareaRef = useRef(null); + const navigate = useNavigate(); + const { reviews, loading, fetchPendingReviews } = useReviewsStore(); const refreshKey = refreshAssignedPage((state) => state.refreshKey); - const selectReview = useCallback((id: string | null) => { - setSelectedId(id); - if (!id) { - setApplicationDetail(null); - setOtherReviewerNotes([]); - } - }, []); + const [selectedReviewId, setSelectedReviewId] = useState(null); + + // Map selected review ID to its application_id for the detail hook + const selectedReview = reviews.find((r) => r.id === selectedReviewId) ?? null; + const selectedApplicationId = selectedReview?.application_id ?? null; + + const { + detail: applicationDetail, + loading: detailLoading, + clear: clearDetail, + } = useApplicationDetail(selectedApplicationId); useEffect(() => { const controller = new AbortController(); @@ -64,130 +50,10 @@ export default function AssignedPage() { return () => controller.abort(); }, [fetchPendingReviews, refreshKey]); - // Fetch full application and other reviewers' notes when a review is selected - useEffect(() => { - if (!selectedId) return; - - const selectedReview = reviews.find((r) => r.id === selectedId); - if (!selectedReview) return; - - const controller = new AbortController(); - - (async () => { - setDetailLoading(true); - setNotesLoading(true); - - const [appRes, notesRes] = await Promise.all([ - getRequest( - `/admin/applications/${selectedReview.application_id}`, - "application", - controller.signal, - ), - getRequest( - `/admin/applications/${selectedReview.application_id}/notes`, - "notes", - controller.signal, - ), - ]); - - if (controller.signal.aborted) return; - - if (appRes.status === 200 && appRes.data) { - setApplicationDetail(appRes.data); - } else { - errorAlert(appRes); - } - - if (notesRes.status === 200 && notesRes.data) { - setOtherReviewerNotes(notesRes.data.notes); - } - - setDetailLoading(false); - setNotesLoading(false); - })(); - - return () => { - controller.abort(); - }; - }, [selectedId, reviews]); - - const selectedReview = reviews.find((r) => r.id === selectedId) ?? null; - - const getVote = useCallback( - (reviewId: string, serverVote: ReviewVote | null): ReviewVote | null => { - return reviewId in localVotes ? localVotes[reviewId] : serverVote; - }, - [localVotes], - ); - - const getNotes = useCallback( - (reviewId: string, serverNotes: string | null): string => { - return reviewId in localNotes - ? localNotes[reviewId] - : (serverNotes ?? ""); - }, - [localNotes], - ); - - const handleVote = useCallback( - async (id: string, vote: ReviewVote) => { - if (submitting) return; - - const review = reviews.find((r) => r.id === id); - if (review?.vote) return; - - const currentIndex = reviews.findIndex((r) => r.id === id); - const nextReview = - reviews[currentIndex + 1] ?? reviews[currentIndex - 1] ?? null; - - const notes = getNotes(id, review?.notes ?? null); - const result = await submitVote(id, { - vote, - notes: notes || undefined, - }); - - if (result.success) { - setLocalVotes((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - setLocalNotes((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - selectReview(nextReview?.id ?? null); - } else { - alert(result.error || "Failed to submit vote"); - } - }, - [submitting, reviews, getNotes, submitVote, selectReview], - ); - - const handleNotesChange = useCallback((id: string, notes: string) => { - setLocalNotes((prev) => ({ ...prev, [id]: notes })); - }, []); - const handleClosePanel = useCallback(() => { - selectReview(null); - setIsExpanded(false); - }, [selectReview]); - - const handleCloseExpanded = useCallback(() => { - setIsExpanded(false); - }, []); - - useReviewKeyboardShortcuts({ - isExpanded, - selectedId, - reviews, - submitting, - notesTextareaRef, - onVote: handleVote, - onNavigate: selectReview, - onCloseExpanded: handleCloseExpanded, - }); + setSelectedReviewId(null); + clearDetail(); + }, [clearDetail]); if (loading && reviews.length === 0) { return ( @@ -207,213 +73,54 @@ export default function AssignedPage() { ); } - const vote = selectedReview - ? getVote(selectedReview.id, selectedReview.vote) - : null; - const notes = selectedReview - ? getNotes(selectedReview.id, selectedReview.notes) - : ""; - return (
- {/* Left: Table */} - {!isExpanded && ( - - - - {reviews.length} review(s) assigned to you - - {reviews.length > 0 && ( - - - - - - Grade{" "} - {formatName(reviews[0].first_name, reviews[0].last_name)} - - - )} - - - - - - )} - - {/* Right: Detail Panel */} - {selectedId && selectedReview && ( - - {/* Header */} -
-
-

- {formatName( - selectedReview.first_name, - selectedReview.last_name, - )} -

- -
-
- {isExpanded ? ( - - - - - Close (Esc) - - ) : ( - <> - - - - - Grade applicant - - - - )} -
-
- - {/* Content area - different layout based on mode */} - {isExpanded ? ( -
- {/* Left: Application details (3/4) */} -
- {detailLoading ? ( -
- {[...Array(4)].map((_, i) => ( -
- - - -
- ))} -
- ) : ( - applicationDetail && ( - - ) - )} -
- - {/* Right: Comments & Vote column (1/4) */} -
-
- - setApplicationDetail((prev) => - prev ? { ...prev, ai_percent: pct } : prev, - ) - } - onNotesChange={handleNotesChange} - onVote={handleVote} - /> -
- {/* Navigation arrows - fixed at bottom of sidebar */} -
-

- Use{" "} - - ← - {" "} - - → - {" "} - arrow keys to navigate -

-
-
-
- ) : ( - - {detailLoading ? ( -
- {[...Array(4)].map((_, i) => ( -
- - - -
- ))} -
- ) : ( - applicationDetail && ( - - ) - )} -
+ + + + {reviews.length} review(s) assigned to you + + {reviews.length > 0 && ( + + + + + + Grade {formatName(reviews[0].first_name, reviews[0].last_name)} + + )} - + +
+ + + +
+ + {selectedReviewId && ( + { + navigate(`/admin/assigned/grade?review=${selectedReviewId}`); + }} + /> )}
); diff --git a/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx b/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx index d86241c2..deab3488 100644 --- a/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx +++ b/client/web/src/pages/admin/assigned/components/ReviewsTable.tsx @@ -1,6 +1,5 @@ import { Maximize2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { Table, TableBody, @@ -10,18 +9,14 @@ import { TableRow, } from "@/components/ui/table"; -import type { Review, ReviewVote } from "../types"; +import type { Review } from "../types"; import { VoteBadge } from "./VoteBadge"; interface ReviewsTableProps { reviews: Review[]; - selectedId: string | null; loading: boolean; + selectedId: string | null; onSelectReview: (id: string) => void; - getVote: ( - reviewId: string, - serverVote: ReviewVote | null, - ) => ReviewVote | null; } function formatName(firstName: string | null, lastName: string | null) { @@ -31,10 +26,9 @@ function formatName(firstName: string | null, lastName: string | null) { export function ReviewsTable({ reviews, - selectedId, loading, + selectedId, onSelectReview, - getVote, }: ReviewsTableProps) { return (
@@ -63,51 +57,34 @@ export function ReviewsTable({ ) : ( - reviews.map((review) => { - const vote = getVote(review.id, review.vote); - return ( - td]:py-3 cursor-pointer ${ - selectedId === review.id ? "bg-muted/50" : "" - }`} - onClick={() => onSelectReview(review.id)} - > - - - - -
- - {formatName(review.first_name, review.last_name)} - - -
-
- {review.email} - {review.age ?? "-"} - {review.university ?? "-"} - {review.major ?? "-"} - {review.country_of_residence ?? "-"} - - {review.hackathons_attended_count ?? "-"} - - - {new Date(review.assigned_at).toLocaleDateString()} - -
- ); - }) + reviews.map((review) => ( + td]:py-3 ${selectedId === review.id ? "bg-muted/50" : ""}`} + onClick={() => onSelectReview(review.id)} + > + + + + +
+ + {formatName(review.first_name, review.last_name)} + + +
+
+ {review.email} + {review.age ?? "-"} + {review.university ?? "-"} + {review.major ?? "-"} + {review.country_of_residence ?? "-"} + {review.hackathons_attended_count ?? "-"} + + {new Date(review.assigned_at).toLocaleDateString()} + +
+ )) )} diff --git a/client/web/src/pages/admin/assigned/grading/GradingPage.tsx b/client/web/src/pages/admin/assigned/grading/GradingPage.tsx new file mode 100644 index 00000000..73040074 --- /dev/null +++ b/client/web/src/pages/admin/assigned/grading/GradingPage.tsx @@ -0,0 +1,200 @@ +import { ArrowLeft, ChevronLeft, ChevronRight } 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 { 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(); + + const reviews = useAdminGradingStore((s) => s.reviews); + const loading = useAdminGradingStore((s) => s.loading); + const currentIndex = useAdminGradingStore((s) => s.currentIndex); + const detail = useAdminGradingStore((s) => s.detail); + const detailLoading = useAdminGradingStore((s) => s.detailLoading); + const otherNotes = useAdminGradingStore((s) => s.notes); + const notesLoading = useAdminGradingStore((s) => s.notesLoading); + const submitting = useAdminGradingStore((s) => s.submitting); + const localNotes = useAdminGradingStore((s) => s.localNotes); + const fetchReviews = useAdminGradingStore((s) => s.fetchReviews); + const loadDetail = useAdminGradingStore((s) => s.loadDetail); + const navigateNext = useAdminGradingStore((s) => s.navigateNext); + const navigatePrev = useAdminGradingStore((s) => s.navigatePrev); + const submitVote = useAdminGradingStore((s) => s.submitVote); + const setLocalNotes = useAdminGradingStore((s) => s.setLocalNotes); + const reset = useAdminGradingStore((s) => s.reset); + + const [aiPercent, setAiPercent] = useState(null); + + const currentReview = reviews[currentIndex] ?? null; + + // Initialize + useEffect(() => { + const targetReviewId = searchParams.get("review"); + + reset(); + fetchReviews().then(() => { + const revs = useAdminGradingStore.getState().reviews; + if (revs.length > 0) { + const targetIndex = targetReviewId + ? revs.findIndex((r) => r.id === targetReviewId) + : -1; + const idx = targetIndex >= 0 ? targetIndex : 0; + useAdminGradingStore.setState({ currentIndex: idx }); + loadDetail(revs[idx].application_id); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync AI percent from detail + useEffect(() => { + setAiPercent(detail?.ai_percent ?? null); + }, [detail]); + + const handleVote = useCallback( + (vote: ReviewVote) => { + if (currentReview && !submitting && !currentReview.vote) { + submitVote(currentReview.id, vote); + } + }, + [currentReview, submitting, submitVote], + ); + + useGradingKeyboardShortcuts({ + submitting, + currentReviewId: currentReview?.id ?? null, + hasVoted: !!currentReview?.vote, + onNavigateNext: navigateNext, + onNavigatePrev: navigatePrev, + onVote: handleVote, + }); + + // 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}` + : "-"} + + +
+
+ + {/* 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/GradingDetailsPanel.tsx b/client/web/src/pages/admin/assigned/grading/components/GradingDetailsPanel.tsx new file mode 100644 index 00000000..233579da --- /dev/null +++ b/client/web/src/pages/admin/assigned/grading/components/GradingDetailsPanel.tsx @@ -0,0 +1,71 @@ +import { memo } from "react"; + +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 { Application } from "@/types"; + +import type { Review } from "../../types"; + +interface GradingDetailsPanelProps { + application: Application | null; + review: Review | null; + loading: boolean; +} + +export const GradingDetailsPanel = memo(function GradingDetailsPanel({ + application, + review, + loading, +}: GradingDetailsPanelProps) { + if (loading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ + + +
+ ))} +
+ ); + } + + if (!application) return null; + + return ( +
+ + + + + + + + + + {review && ( +
+

+ Review Details +

+
+ Application ID + {review.application_id} + Assigned at + {new Date(review.assigned_at).toLocaleString()} +
+
+ )} +
+ ); +}); diff --git a/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx b/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx new file mode 100644 index 00000000..71ccfc5e --- /dev/null +++ b/client/web/src/pages/admin/assigned/grading/components/GradingVotingPanel.tsx @@ -0,0 +1,297 @@ +import { + Check, + Loader2, + MessageSquare, + Minus, + Pencil, + ThumbsDown, + ThumbsUp, + X, +} from "lucide-react"; +import { memo, useRef, 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 { NotesTextarea } from "../../components/NotesTextarea"; +import { VoteBadge } from "../../components/VoteBadge"; +import type { Review, ReviewNote, ReviewVote } from "../../types"; + +interface GradingVotingPanelProps { + review: Review; + notes: string; + otherReviewerNotes: ReviewNote[]; + notesLoading: boolean; + submitting: boolean; + aiPercent: number | null; + onAiPercentUpdate: (percent: number) => void; + onNotesChange: (notes: string) => void; + onVote: (vote: ReviewVote) => void; +} + +export const GradingVotingPanel = memo(function GradingVotingPanel({ + review, + notes, + otherReviewerNotes, + notesLoading, + submitting, + aiPercent, + onAiPercentUpdate, + onNotesChange, + onVote, +}: GradingVotingPanelProps) { + const [editing, setEditing] = useState(false); + const [inputValue, setInputValue] = useState(""); + const notesTextareaRef = useRef(null); + + 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(review.application_id, { + 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 */} +
+
+ + +
+ {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 */} +
+
+ + {!review.vote && ( + + Write notes before casting your vote + + )} +
+ onNotesChange(value)} + /> +
+ + {/* AI Percent */} +
+ + {editing ? ( +
+ setInputValue(e.target.value)} + className="h-7 w-24 text-sm" + autoFocus + /> + + +
+ ) : aiPercent != null ? ( +
+

{aiPercent}%

+ +
+ ) : ( +
+

Not set

+ +
+ )} +
+ + {/* Vote Section */} + {review.vote ? ( +
+

+ You voted: +

+ {review.reviewed_at && ( +

+ {new Date(review.reviewed_at).toLocaleString()} +

+ )} +
+ ) : ( +
+ +
+ + + + + Reject (⌘J) + + + + + + Waitlist (⌘K) + + + + + + Accept (⌘L) + +
+ {submitting && ( +

+ Submitting vote... +

+ )} +
+ )} +
+ ); +}); diff --git a/client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts b/client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts new file mode 100644 index 00000000..ad7bcb6c --- /dev/null +++ b/client/web/src/pages/admin/assigned/grading/hooks/useGradingKeyboardShortcuts.ts @@ -0,0 +1,84 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +import type { ReviewVote } from "../../types"; + +interface UseGradingKeyboardShortcutsOptions { + submitting: boolean; + currentReviewId: string | null; + hasVoted: boolean; + onNavigateNext: () => void; + onNavigatePrev: () => void; + onVote: (vote: ReviewVote) => void; +} + +export function useGradingKeyboardShortcuts({ + submitting, + currentReviewId, + hasVoted, + onNavigateNext, + onNavigatePrev, + onVote, +}: 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 assigned page + if (e.key === "Escape") { + e.preventDefault(); + navigate("/admin/assigned"); + 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; + } + } + + // Cmd/Ctrl + J/K/L: Vote shortcuts + if ( + (e.metaKey || e.ctrlKey) && + currentReviewId && + !submitting && + !hasVoted + ) { + if (e.key === "j") { + e.preventDefault(); + onVote("reject"); + } else if (e.key === "k") { + e.preventDefault(); + onVote("waitlist"); + } else if (e.key === "l") { + e.preventDefault(); + onVote("accept"); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [ + submitting, + currentReviewId, + hasVoted, + onNavigateNext, + onNavigatePrev, + onVote, + navigate, + ]); +} diff --git a/client/web/src/pages/admin/assigned/grading/store.ts b/client/web/src/pages/admin/assigned/grading/store.ts new file mode 100644 index 00000000..3d194401 --- /dev/null +++ b/client/web/src/pages/admin/assigned/grading/store.ts @@ -0,0 +1,146 @@ +import { toast } from "sonner"; +import { create } from "zustand"; + +import { fetchApplicationById } from "@/pages/admin/all-applicants/api"; +import type { Application } from "@/types"; + +import { + fetchPendingReviews, + fetchReviewNotes, + submitReviewVote, +} from "../api"; +import type { Review, ReviewNote, ReviewVote } from "../types"; + +interface GradingState { + reviews: Review[]; + loading: boolean; + currentIndex: number; + detail: Application | null; + detailLoading: boolean; + notes: ReviewNote[]; + notesLoading: boolean; + submitting: boolean; + localNotes: string; + + fetchReviews: () => Promise; + loadDetail: (applicationId: string) => Promise; + navigateNext: () => void; + navigatePrev: () => void; + submitVote: (reviewId: string, vote: ReviewVote) => Promise; + setLocalNotes: (notes: string) => void; + reset: () => void; +} + +const initialState = { + reviews: [] as Review[], + loading: false, + currentIndex: 0, + detail: null as Application | null, + detailLoading: false, + notes: [] as ReviewNote[], + notesLoading: false, + submitting: false, + localNotes: "", +}; + +export const useAdminGradingStore = create((set, get) => ({ + ...initialState, + + fetchReviews: async () => { + set({ loading: true }); + const res = await fetchPendingReviews(); + + if (res.status === 200 && res.data) { + set({ reviews: res.data.reviews, loading: false }); + } else { + set({ reviews: [], loading: false }); + } + }, + + loadDetail: async (applicationId: string) => { + set({ + detailLoading: true, + notesLoading: true, + detail: null, + notes: [], + localNotes: "", + }); + + const [detailRes, notesRes] = await Promise.all([ + fetchApplicationById(applicationId), + fetchReviewNotes(applicationId), + ]); + + if (detailRes.status === 200 && detailRes.data) { + set({ detail: detailRes.data, detailLoading: false }); + } else { + set({ detail: null, detailLoading: false }); + } + + if (notesRes.status === 200 && notesRes.data) { + set({ notes: notesRes.data.notes ?? [], notesLoading: false }); + } else { + set({ notes: [], notesLoading: false }); + } + }, + + navigateNext: () => { + const { reviews, currentIndex } = get(); + if (currentIndex < reviews.length - 1) { + const newIndex = currentIndex + 1; + set({ currentIndex: newIndex }); + get().loadDetail(reviews[newIndex].application_id); + } + }, + + navigatePrev: () => { + const { reviews, currentIndex } = get(); + if (currentIndex > 0) { + const newIndex = currentIndex - 1; + set({ currentIndex: newIndex }); + get().loadDetail(reviews[newIndex].application_id); + } + }, + + submitVote: async (reviewId: string, vote: ReviewVote) => { + set({ submitting: true }); + + const { localNotes } = get(); + const result = await submitReviewVote(reviewId, { + vote, + notes: localNotes || undefined, + }); + + if (result.success) { + const { reviews, currentIndex } = get(); + const filtered = reviews.filter((r) => r.id !== reviewId); + const newIndex = Math.min(currentIndex, filtered.length - 1); + + set({ + reviews: filtered, + currentIndex: Math.max(0, newIndex), + submitting: false, + localNotes: "", + }); + + toast.success(`Vote submitted: ${vote}`); + + if (filtered.length > 0) { + get().loadDetail(filtered[Math.max(0, newIndex)].application_id); + } else { + set({ detail: null, notes: [] }); + } + } else { + set({ submitting: false }); + toast.error(result.error ?? "Failed to submit vote"); + } + }, + + setLocalNotes: (notes: string) => { + set({ localNotes: notes }); + }, + + reset: () => { + set(initialState); + }, +})); diff --git a/client/web/src/pages/superadmin/application/ApplicationPage.tsx b/client/web/src/pages/superadmin/application/ApplicationPage.tsx index ea377b9b..140f437d 100644 --- a/client/web/src/pages/superadmin/application/ApplicationPage.tsx +++ b/client/web/src/pages/superadmin/application/ApplicationPage.tsx @@ -1,7 +1,18 @@ -import { Loader2, Plus, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Plus, Save, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -20,11 +31,7 @@ import { ApplicationPreview } from "./components/ApplicationPreview"; export default function ApplicationPage() { const [questions, setQuestions] = useState([]); const [loading, setLoading] = useState(false); - const saveTimerRef = useRef | null>(null); - const questionsRef = useRef(questions); - useEffect(() => { - questionsRef.current = questions; - }, [questions]); + const [saving, setSaving] = useState(false); useEffect(() => { const fetchQuestions = async () => { @@ -43,11 +50,15 @@ export default function ApplicationPage() { fetchQuestions(); }, []); - const saveQuestions = useCallback(async (qs: ShortAnswerQuestion[]) => { - const emptyQuestion = qs.find((q) => !q.question.trim()); - if (emptyQuestion) return; + const saveQuestions = useCallback(async () => { + const emptyQuestion = questions.find((q) => !q.question.trim()); + if (emptyQuestion) { + toast.error("All questions must have text before saving"); + return; + } - const payload = qs.map((q, i) => ({ ...q, display_order: i + 1 })); + setSaving(true); + const payload = questions.map((q, i) => ({ ...q, display_order: i + 1 })); const res = await putRequest<{ questions: ShortAnswerQuestion[] }>( "/superadmin/settings/saquestions", { questions: payload }, @@ -58,32 +69,14 @@ export default function ApplicationPage() { } else { errorAlert(res); } - }, []); - - const debouncedSave = useCallback( - (qs: ShortAnswerQuestion[]) => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => saveQuestions(qs), 1000); - }, - [saveQuestions], - ); - - // Clean up timer on unmount - useEffect(() => { - return () => { - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - }; - }, []); + setSaving(false); + }, [questions]); const updateQuestions = useCallback( (updater: (prev: ShortAnswerQuestion[]) => ShortAnswerQuestion[]) => { - setQuestions((prev) => { - const next = updater(prev); - debouncedSave(next); - return next; - }); + setQuestions((prev) => updater(prev)); }, - [debouncedSave], + [], ); const updateQuestion = ( @@ -198,6 +191,40 @@ export default function ApplicationPage() { Add Question + + + + + + + + Save questions? + + This will affect all hacker + applications. Are you sure you want to save these + changes? + + + + + Cancel + + + Save + + + +
)} diff --git a/client/web/src/pages/superadmin/emails/EmailsPage.tsx b/client/web/src/pages/superadmin/emails/EmailsPage.tsx deleted file mode 100644 index 282bacc7..00000000 --- a/client/web/src/pages/superadmin/emails/EmailsPage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const SuperAdminEmailsPage = () => { - return
SuperAdminEmailsPage
; -}; - -export default SuperAdminEmailsPage; diff --git a/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx b/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx index 2d14a822..a8013897 100644 --- a/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx +++ b/client/web/src/pages/superadmin/reviews/ReviewsPage.tsx @@ -1,12 +1,19 @@ import { + ArrowDown, + ClipboardCheck, ClipboardList, + Download, Loader2, + Mail, Minus, Plus, + Search, Shuffle, ToggleRight, + TriangleAlert, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { @@ -19,19 +26,42 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, + CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; +import { ApplicationDetailPanel } from "@/pages/admin/all-applicants/components/ApplicationDetailPanel"; +import { PaginationControls } from "@/pages/admin/all-applicants/components/PaginationControls"; +import { useApplicationDetail } from "@/pages/admin/all-applicants/hooks/useApplicationDetail"; +import type { + ApplicationSortBy, + ApplicationStatus, +} from "@/pages/admin/all-applicants/types"; +import { getStatusColor } from "@/pages/admin/all-applicants/utils"; import type { AssignedState } from "@/pages/admin/assigned/hooks/updateReviewPage"; import { refreshAssignedPage } from "@/pages/admin/assigned/hooks/updateReviewPage"; import { errorAlert, getRequest, postRequest } from "@/shared/lib/api"; +import { fetchApplicantEmails } from "./api"; +import { ReviewsTable } from "./components/ReviewsTable"; +import { ReviewStatusTabs } from "./components/ReviewStatusTabs"; +import { useReviewApplicationsStore } from "./store"; + export default function ReviewsPage() { + const navigate = useNavigate(); const [reviewsPerApp, setReviewsPerApp] = useState(1); const [loading, setLoading] = useState(true); const [savingCount, setSavingCount] = useState(false); @@ -45,6 +75,32 @@ export default function ReviewsPage() { (state: AssignedState) => state.triggerRefresh, ); + // Applications table state + const applications = useReviewApplicationsStore((s) => s.applications); + const tableLoading = useReviewApplicationsStore((s) => s.loading); + const nextCursor = useReviewApplicationsStore((s) => s.nextCursor); + const prevCursor = useReviewApplicationsStore((s) => s.prevCursor); + const currentStatus = useReviewApplicationsStore((s) => s.currentStatus); + const currentSearch = useReviewApplicationsStore((s) => s.currentSearch); + const currentSortBy = useReviewApplicationsStore((s) => s.currentSortBy); + const stats = useReviewApplicationsStore((s) => s.stats); + const fetchApplications = useReviewApplicationsStore( + (s) => s.fetchApplications, + ); + const fetchStats = useReviewApplicationsStore((s) => s.fetchStats); + + const [emailStatus, setEmailStatus] = useState(null); + const [downloadingCsv, setDownloadingCsv] = useState(false); + const [searchInput, setSearchInput] = useState(currentSearch); + const [selectedApplicationId, setSelectedApplicationId] = useState< + string | null + >(null); + const { + detail: applicationDetail, + loading: detailLoading, + clear: clearDetail, + } = useApplicationDetail(selectedApplicationId); + useEffect(() => { async function fetchData() { const [reviewsRes, toggleRes] = await Promise.all([ @@ -69,6 +125,60 @@ export default function ReviewsPage() { fetchData(); }, []); + // Fetch applications and stats on mount + useEffect(() => { + const controller = new AbortController(); + fetchApplications(undefined, controller.signal); + fetchStats(controller.signal); + return () => controller.abort(); + }, [fetchApplications, fetchStats]); + + // Debounced search + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + const timer = setTimeout(() => { + fetchApplications({ + search: searchInput.length >= 2 ? searchInput : "", + }); + }, 500); + return () => clearTimeout(timer); + }, [searchInput, fetchApplications]); + + const handleClosePanel = useCallback(() => { + setSelectedApplicationId(null); + clearDetail(); + }, [clearDetail]); + + const handleSortChange = useCallback( + (newSortBy: ApplicationSortBy) => { + fetchApplications({ sort_by: newSortBy }); + }, + [fetchApplications], + ); + + const handleStatusFilter = useCallback( + (status: ApplicationStatus) => { + fetchApplications({ status }); + }, + [fetchApplications], + ); + + const handleNextPage = useCallback(() => { + if (nextCursor) { + fetchApplications({ cursor: nextCursor }); + } + }, [nextCursor, fetchApplications]); + + const handlePrevPage = useCallback(() => { + if (prevCursor) { + fetchApplications({ cursor: prevCursor, direction: "backward" }); + } + }, [prevCursor, fetchApplications]); + async function updateReviewsPerApp(newValue: number) { const clamped = Math.max(1, Math.min(10, newValue)); setReviewsPerApp(clamped); @@ -124,6 +234,41 @@ export default function ReviewsPage() { setTogglingAssignment(false); } + async function handleGenerateCsv() { + if (!emailStatus) return; + setDownloadingCsv(true); + const res = await fetchApplicantEmails(emailStatus); + if (res.status !== 200 || !res.data) { + errorAlert(res); + setDownloadingCsv(false); + return; + } + + const csvEscape = (value: string | null) => { + const str = value ?? ""; + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + + const header = "email,first_name,last_name"; + const rows = res.data.applicants.map( + (a) => + `${csvEscape(a.email)},${csvEscape(a.first_name)},${csvEscape(a.last_name)}`, + ); + const csv = [header, ...rows].join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${emailStatus}_applicants.csv`; + link.click(); + URL.revokeObjectURL(url); + setDownloadingCsv(false); + } + if (loading) { return (
@@ -140,8 +285,8 @@ export default function ReviewsPage() { } return ( -
-
+
+
{/* Reviews Per Application */} @@ -218,7 +363,7 @@ export default function ReviewsPage() {
+ {/* Applications Table Section */} +
+
+ +
+
+ + setSearchInput(e.target.value)} + /> +
+
+ +
+
+ +
+ + +
+ + {applications.length} application(s) on this page + filtered by + + {currentStatus} + + {currentSearch && matching "{currentSearch}"} + + + {currentSortBy === "accept_votes" + ? "accept votes" + : currentSortBy === "reject_votes" + ? "reject votess" + : currentSortBy === "waitlist_votes" + ? "waitlist votes" + : "date created"} + + +
+ + + + + + +

+ Select status to export +

+ setEmailStatus(value)} + className="gap-2" + > + {( + [ + { key: "accepted", label: "Accepted" }, + { key: "waitlisted", label: "Waitlisted" }, + { key: "rejected", label: "Rejected" }, + ] as const + ).map(({ key, label }) => ( + + ))} + + +

+ email, first name, last name +

+ {stats && stats.submitted > 0 && ( +
+ +

+ {stats.submitted} application(s) still in submitted + status +

+
+ )} +
+
+
+
+
+
+ + + +
+ + {selectedApplicationId && ( + { + const params = new URLSearchParams(); + if (currentStatus) params.set("status", currentStatus); + if (currentSortBy) params.set("sort_by", currentSortBy); + if (currentSearch) params.set("search", currentSearch); + params.set("app", selectedApplicationId); + navigate(`/admin/sa/reviews/grade?${params.toString()}`); + }} + /> + )} +
+ @@ -252,7 +576,7 @@ export default function ReviewsPage() { Yes, Assign Reviews diff --git a/client/web/src/pages/superadmin/reviews/api.ts b/client/web/src/pages/superadmin/reviews/api.ts new file mode 100644 index 00000000..53976c44 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/api.ts @@ -0,0 +1,19 @@ +import { getRequest } from "@/shared/lib/api"; + +interface ApplicantEmail { + email: string; + first_name: string | null; + last_name: string | null; +} + +interface EmailListResponse { + applicants: ApplicantEmail[]; + count: number; +} + +export async function fetchApplicantEmails(status: string) { + return getRequest( + `/superadmin/applications/emails?status=${status}`, + "applicant emails", + ); +} diff --git a/client/web/src/pages/superadmin/reviews/components/ReviewStatusTabs.tsx b/client/web/src/pages/superadmin/reviews/components/ReviewStatusTabs.tsx new file mode 100644 index 00000000..ae9945b5 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/components/ReviewStatusTabs.tsx @@ -0,0 +1,61 @@ +import { memo } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { + ApplicationStats, + ApplicationStatus, +} from "@/pages/admin/all-applicants/types"; + +const STATUSES: { value: ApplicationStatus; label: string }[] = [ + { value: "submitted", label: "Submitted" }, + { value: "accepted", label: "Accepted" }, + { value: "waitlisted", label: "Waitlisted" }, + { value: "rejected", label: "Rejected" }, +]; + +interface ReviewStatusTabsProps { + stats: ApplicationStats | null; + loading: boolean; + currentStatus: ApplicationStatus; + onStatusChange: (status: ApplicationStatus) => void; +} + +export const ReviewStatusTabs = memo(function ReviewStatusTabs({ + stats, + loading, + currentStatus, + onStatusChange, +}: ReviewStatusTabsProps) { + return ( + onStatusChange(value as ApplicationStatus)} + className="min-w-0" + > + + {STATUSES.map(({ value, label }) => { + const count = stats?.[value] ?? 0; + return ( + + {label} + {stats && count > 0 && ( + + {count} + + )} + + ); + })} + + + ); +}); diff --git a/client/web/src/pages/superadmin/reviews/components/ReviewsTable.tsx b/client/web/src/pages/superadmin/reviews/components/ReviewsTable.tsx new file mode 100644 index 00000000..1eb99269 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/components/ReviewsTable.tsx @@ -0,0 +1,142 @@ +import { ArrowDown, ChevronsUpDown, Maximize2 } from "lucide-react"; +import { memo } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { + ApplicationListItem, + ApplicationSortBy, +} from "@/pages/admin/all-applicants/types"; +import { formatName, getStatusColor } from "@/pages/admin/all-applicants/utils"; + +interface ReviewsTableProps { + applications: ApplicationListItem[]; + loading: boolean; + selectedId: string | null; + onSelectApplication: (id: string) => void; + sortBy: ApplicationSortBy; + onSortChange: (sortBy: ApplicationSortBy) => void; +} + +type SortableColumn = Exclude; + +const SORTABLE_COLUMNS: { key: SortableColumn; label: string }[] = [ + { key: "accept_votes", label: "Accept" }, + { key: "reject_votes", label: "Reject" }, + { key: "waitlist_votes", label: "Waitlist" }, +]; + +export const ReviewsTable = memo(function ReviewsTable({ + applications, + loading, + selectedId, + onSelectApplication, + sortBy, + onSortChange, +}: ReviewsTableProps) { + function handleSortClick(column: SortableColumn) { + if (sortBy === column) { + onSortChange("created_at"); + } else { + onSortChange(column); + } + } + + return ( +
+ {loading && ( +
+ )} + + + + Status + Name + Email + {SORTABLE_COLUMNS.map((col) => ( + + + + ))} + Reviews + AI % + Submitted + Created + + + + {applications.length === 0 ? ( + + + No applications found + + + ) : ( + applications.map((app) => ( + td]:py-3 ${selectedId === app.id ? "bg-muted/50" : ""}`} + onClick={() => onSelectApplication(app.id)} + > + + + {app.status} + + + +
+ {formatName(app.first_name, app.last_name)} + +
+
+ {app.email} + + {app.accept_votes} + + + {app.reject_votes} + + + {app.waitlist_votes} + + + {app.reviews_completed}/{app.reviews_assigned} + + + {app.ai_percent != null ? `${app.ai_percent}%` : "-"} + + + {app.submitted_at + ? new Date(app.submitted_at).toLocaleDateString() + : "-"} + + + {new Date(app.created_at).toLocaleDateString()} + +
+ )) + )} +
+
+
+ ); +}); diff --git a/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx b/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx new file mode 100644 index 00000000..4c8713cc --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/GradingPage.tsx @@ -0,0 +1,200 @@ +import { ArrowLeft, ChevronLeft, ChevronRight } 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 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() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const applications = useGradingStore((s) => s.applications); + const loading = useGradingStore((s) => s.loading); + const currentIndex = useGradingStore((s) => s.currentIndex); + const detail = useGradingStore((s) => s.detail); + const detailLoading = useGradingStore((s) => s.detailLoading); + const notes = useGradingStore((s) => s.notes); + const notesLoading = useGradingStore((s) => s.notesLoading); + const grading = useGradingStore((s) => s.grading); + const fetchApplications = useGradingStore((s) => s.fetchApplications); + const loadDetail = useGradingStore((s) => s.loadDetail); + const navigateNext = useGradingStore((s) => s.navigateNext); + const navigatePrev = useGradingStore((s) => s.navigatePrev); + const gradeApplication = useGradingStore((s) => s.gradeApplication); + const reset = useGradingStore((s) => s.reset); + + const currentApp = applications[currentIndex] ?? null; + + // Initialize from URL params and reset stale state + useEffect(() => { + const status = + (searchParams.get("status") as ApplicationStatus) || "submitted"; + const sort_by = + (searchParams.get("sort_by") as ApplicationSortBy) || "accept_votes"; + const search = searchParams.get("search") || ""; + const targetAppId = searchParams.get("app"); + + reset(); + useGradingStore.setState({ + filterParams: { status, sort_by, search: search || undefined }, + }); + + fetchApplications({ + status, + sort_by, + search: search || undefined, + }).then(() => { + const apps = useGradingStore.getState().applications; + if (apps.length > 0) { + const targetIndex = targetAppId + ? apps.findIndex((a) => a.id === targetAppId) + : -1; + const idx = targetIndex >= 0 ? targetIndex : 0; + useGradingStore.setState({ currentIndex: idx }); + loadDetail(apps[idx].id); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleGrade = useCallback( + (status: "accepted" | "rejected" | "waitlisted") => { + if (currentApp) { + gradeApplication(currentApp.id, status); + } + }, + [currentApp, gradeApplication], + ); + + useGradingKeyboardShortcuts({ + grading, + currentApplicationId: currentApp?.id ?? null, + onNavigateNext: navigateNext, + onNavigatePrev: navigatePrev, + onGrade: handleGrade, + }); + + // 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)} +

+ + {currentApp.status} + + + ) : null} + +
+ + + {applications.length > 0 + ? `${currentIndex + 1} of ${applications.length}` + : "-"} + + +
+
+ + {/* 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/api.ts b/client/web/src/pages/superadmin/reviews/grading/api.ts new file mode 100644 index 00000000..c6907495 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/api.ts @@ -0,0 +1,13 @@ +import { patchRequest } from "@/shared/lib/api"; +import type { ApiResponse, Application } from "@/types"; + +export async function setApplicationStatus( + id: string, + status: "accepted" | "rejected" | "waitlisted", +): Promise> { + return patchRequest<{ application: Application }>( + `/superadmin/applications/${id}/status`, + { status }, + "application status", + ); +} diff --git a/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx b/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx new file mode 100644 index 00000000..42043e56 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/components/GradingDetailsPanel.tsx @@ -0,0 +1,86 @@ +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 new file mode 100644 index 00000000..7bb1f6c9 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/components/GradingPanel.tsx @@ -0,0 +1,183 @@ +import { + Loader2, + MessageSquare, + 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"; +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"; + +interface GradingPanelProps { + listItem: ApplicationListItem | null; + notes: ReviewNote[]; + notesLoading: boolean; + grading: boolean; + onGrade: (status: "accepted" | "rejected" | "waitlisted") => void; +} + +export const GradingPanel = memo(function GradingPanel({ + listItem, + notes, + notesLoading, + grading, + onGrade, +}: GradingPanelProps) { + if (!listItem) return null; + + return ( +
+ {/* Current Status */} +
+ +
+ + {listItem.status} + +
+
+ + {/* Vote Summary */} +
+ +

+ {listItem.reviews_completed} / {listItem.reviews_assigned} reviews + completed +

+
+ + + {listItem.accept_votes} + + + + {listItem.reject_votes} + + + + {listItem.waitlist_votes} + +
+
+ + {/* 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) + +
+
+
+ ); +}); diff --git a/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts b/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts new file mode 100644 index 00000000..ac4b1eca --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/hooks/useGradingKeyboardShortcuts.ts @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..7f11cb99 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/grading/store.ts @@ -0,0 +1,198 @@ +import { toast } from "sonner"; +import { create } from "zustand"; + +import { + fetchApplicationById, + fetchApplications as apiFetchApplications, +} from "@/pages/admin/all-applicants/api"; +import type { + ApplicationListItem, + ApplicationSortBy, + ApplicationStatus, + FetchParams, +} from "@/pages/admin/all-applicants/types"; +import { fetchReviewNotes } from "@/pages/admin/assigned/api"; +import type { ReviewNote } from "@/pages/admin/assigned/types"; +import type { Application } from "@/types"; + +import { setApplicationStatus } from "./api"; + +interface FilterParams { + status?: ApplicationStatus; + sort_by?: ApplicationSortBy; + search?: string; +} + +interface GradingState { + applications: ApplicationListItem[]; + loading: boolean; + currentIndex: number; + detail: Application | null; + detailLoading: boolean; + notes: ReviewNote[]; + notesLoading: boolean; + grading: boolean; + nextCursor: string | null; + prevCursor: string | null; + filterParams: FilterParams; + + fetchApplications: (params?: FetchParams) => Promise; + loadDetail: (applicationId: string) => Promise; + navigateNext: () => void; + navigatePrev: () => void; + gradeApplication: ( + applicationId: string, + status: "accepted" | "rejected" | "waitlisted", + ) => Promise; + reset: () => void; +} + +const initialState = { + applications: [] as ApplicationListItem[], + loading: false, + currentIndex: 0, + detail: null as Application | null, + detailLoading: false, + notes: [] as ReviewNote[], + notesLoading: false, + grading: false, + nextCursor: null as string | null, + prevCursor: null as string | null, + filterParams: {} as FilterParams, +}; + +export const useGradingStore = create((set, get) => ({ + ...initialState, + + fetchApplications: async (params?: FetchParams) => { + set({ loading: true }); + + const state = get(); + const mergedParams: FetchParams = { + status: state.filterParams.status ?? "submitted", + sort_by: state.filterParams.sort_by ?? "accept_votes", + search: state.filterParams.search || undefined, + ...params, + }; + + const res = await apiFetchApplications(mergedParams); + + if (res.status === 200 && res.data) { + set({ + applications: res.data.applications, + nextCursor: res.data.next_cursor, + prevCursor: res.data.prev_cursor, + loading: false, + filterParams: { + status: mergedParams.status ?? undefined, + sort_by: mergedParams.sort_by, + search: mergedParams.search, + }, + }); + } else { + set({ + applications: [], + nextCursor: null, + prevCursor: null, + loading: false, + }); + } + }, + + loadDetail: async (applicationId: string) => { + set({ detailLoading: true, notesLoading: true, detail: null, notes: [] }); + + const [detailRes, notesRes] = await Promise.all([ + fetchApplicationById(applicationId), + fetchReviewNotes(applicationId), + ]); + + if (detailRes.status === 200 && detailRes.data) { + set({ detail: detailRes.data, detailLoading: false }); + } else { + set({ detail: null, detailLoading: false }); + } + + if (notesRes.status === 200 && notesRes.data) { + set({ notes: notesRes.data.notes ?? [], notesLoading: false }); + } else { + set({ notes: [], notesLoading: false }); + } + }, + + navigateNext: () => { + const { applications, currentIndex, nextCursor } = get(); + if (currentIndex < applications.length - 1) { + const newIndex = currentIndex + 1; + set({ currentIndex: newIndex }); + get().loadDetail(applications[newIndex].id); + } else if (nextCursor) { + get() + .fetchApplications({ cursor: nextCursor }) + .then(() => { + const newApps = get().applications; + if (newApps.length > 0) { + set({ currentIndex: 0 }); + get().loadDetail(newApps[0].id); + } + }) + .catch(() => {}); + } + }, + + navigatePrev: () => { + const { applications, currentIndex, prevCursor } = get(); + if (currentIndex > 0) { + const newIndex = currentIndex - 1; + set({ currentIndex: newIndex }); + get().loadDetail(applications[newIndex].id); + } else if (prevCursor) { + get() + .fetchApplications({ cursor: prevCursor, direction: "backward" }) + .then(() => { + const newApps = get().applications; + if (newApps.length > 0) { + const lastIndex = newApps.length - 1; + set({ currentIndex: lastIndex }); + get().loadDetail(newApps[lastIndex].id); + } + }) + .catch(() => {}); + } + }, + + gradeApplication: async ( + applicationId: string, + status: "accepted" | "rejected" | "waitlisted", + ) => { + set({ grading: true }); + + const res = await setApplicationStatus(applicationId, status); + + if (res.status === 200) { + const { applications, currentIndex } = get(); + const updated = applications.map((app) => + app.id === applicationId ? { ...app, status } : app, + ); + set({ applications: updated, grading: false }); + + toast.success(`Application ${status}`); + + // Auto-advance to next + if (currentIndex < updated.length - 1) { + const newIndex = currentIndex + 1; + set({ currentIndex: newIndex }); + get().loadDetail(updated[newIndex].id); + } else if (get().nextCursor) { + get().navigateNext(); + } + } else { + set({ grading: false }); + toast.error(res.error ?? "Failed to update application status"); + } + }, + + reset: () => { + set(initialState); + }, +})); diff --git a/client/web/src/pages/superadmin/reviews/store.ts b/client/web/src/pages/superadmin/reviews/store.ts new file mode 100644 index 00000000..019c3681 --- /dev/null +++ b/client/web/src/pages/superadmin/reviews/store.ts @@ -0,0 +1,118 @@ +import { create } from "zustand"; + +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 }); + } + }, + }), +); diff --git a/client/web/src/routes.tsx b/client/web/src/routes.tsx index fa2578ed..3d45bce2 100644 --- a/client/web/src/routes.tsx +++ b/client/web/src/routes.tsx @@ -37,11 +37,14 @@ const SuperAdminApplicationPage = lazy( const SuperAdminReviewsPage = lazy( () => import("@/pages/superadmin/reviews/ReviewsPage"), ); +const SuperAdminGradingPage = lazy( + () => import("@/pages/superadmin/reviews/grading/GradingPage"), +); const SuperAdminScansPage = lazy( () => import("@/pages/superadmin/scans/ScansPage"), ); -const SuperAdminEmailsPage = lazy( - () => import("@/pages/superadmin/emails/EmailsPage"), +const AssignedGradingPage = lazy( + () => import("@/pages/admin/assigned/grading/GradingPage"), ); export const router = createBrowserRouter([ @@ -134,6 +137,14 @@ export const router = createBrowserRouter([ ), }, + { + path: "assigned/grade", + element: ( + }> + + + ), + }, { path: "completed", element: ( @@ -190,21 +201,21 @@ export const router = createBrowserRouter([ ), }, { - path: "sa/scans", + path: "sa/reviews/grade", element: ( }> - + ), }, { - path: "sa/emails", + path: "sa/scans", element: ( }> - + ), diff --git a/cmd/api/api.go b/cmd/api/api.go index 8673d85b..49ee518e 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -178,8 +178,6 @@ func (app *application) mount() http.Handler { // Scans Config r.Put("/settings/scan-types", app.updateScanTypesHandler) - // Emails - r.Post("/emails/qr", app.sendQREmailsHandler) }) }) }) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index e25eed3c..f1c40b65 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -420,6 +420,7 @@ func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *htt // @Param status query string false "Filter by status (draft, submitted, accepted, rejected, waitlisted)" // @Param limit query int false "Page size (default 50, max 100)" // @Param direction query string false "Pagination direction: forward (default) or backward" +// @Param sort_by query string false "Sort column: created_at (default), accept_votes, reject_votes, waitlist_votes" // @Success 200 {object} store.ApplicationListResult // @Failure 400 {object} object{error=string} // @Failure 401 {object} object{error=string} @@ -491,6 +492,18 @@ func (app *application) listApplicationsHandler(w http.ResponseWriter, r *http.R } } + // Parse sort_by + if sortStr := query.Get("sort_by"); sortStr != "" { + switch store.ApplicationSortBy(sortStr) { + case store.SortByCreatedAt, store.SortByAcceptVotes, + store.SortByRejectVotes, store.SortByWaitlistVotes: + filters.SortBy = store.ApplicationSortBy(sortStr) + default: + app.badRequestResponse(w, r, errors.New("invalid sort_by value")) + return + } + } + result, err := app.store.Application.List(r.Context(), filters, cursor, direction, limit) if err != nil { app.internalServerError(w, r, err) @@ -510,9 +523,15 @@ type ApplicationResponse struct { Application *store.Application `json:"application"` } +type ApplicantInfo struct { + Email string `json:"email"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` +} + type EmailListResponse struct { - Emails []string `json:"emails"` - Count int `json:"count"` + Applicants []ApplicantInfo `json:"applicants"` + Count int `json:"count"` } // setApplicationStatus sets the final status on an application @@ -649,14 +668,18 @@ func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter, return } - emails := make([]string, len(users)) + applicants := make([]ApplicantInfo, len(users)) for i, u := range users { - emails[i] = u.Email + applicants[i] = ApplicantInfo{ + Email: u.Email, + FirstName: u.FirstName, + LastName: u.LastName, + } } response := EmailListResponse{ - Emails: emails, - Count: len(emails), + Applicants: applicants, + Count: len(applicants), } if err = app.jsonResponse(w, http.StatusOK, response); err != nil { diff --git a/cmd/api/applications_test.go b/cmd/api/applications_test.go index 5578702c..e4e67d2b 100644 --- a/cmd/api/applications_test.go +++ b/cmd/api/applications_test.go @@ -465,6 +465,62 @@ func TestListApplications(t *testing.T) { mockApps.AssertExpectations(t) }) + + t.Run("should return 400 for invalid sort_by", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/?sort_by=invalid", nil) + require.NoError(t, err) + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.listApplicationsHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("should accept valid sort_by accept_votes", func(t *testing.T) { + result := &store.ApplicationListResult{ + Applications: []store.ApplicationListItem{}, + HasMore: false, + } + + mockApps.On("List", + store.ApplicationListFilters{SortBy: store.SortByAcceptVotes}, + (*store.ApplicationCursor)(nil), + store.DirectionForward, + 50, + ).Return(result, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/?sort_by=accept_votes", nil) + require.NoError(t, err) + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.listApplicationsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + mockApps.AssertExpectations(t) + }) + + t.Run("should accept sort_by with status filter", func(t *testing.T) { + status := store.StatusSubmitted + result := &store.ApplicationListResult{ + Applications: []store.ApplicationListItem{}, + HasMore: false, + } + + mockApps.On("List", + store.ApplicationListFilters{Status: &status, SortBy: store.SortByRejectVotes}, + (*store.ApplicationCursor)(nil), + store.DirectionForward, + 50, + ).Return(result, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/?status=submitted&sort_by=reject_votes", nil) + require.NoError(t, err) + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.listApplicationsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + mockApps.AssertExpectations(t) + }) } func TestSetApplicationStatus(t *testing.T) { diff --git a/cmd/api/emails.go b/cmd/api/emails.go deleted file mode 100644 index e7353975..00000000 --- a/cmd/api/emails.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "sync" - "sync/atomic" - - "github.com/hackutd/portal/internal/store" -) - -type SendQREmailsResponse struct { - Total int `json:"total"` - Sent int `json:"sent"` - Failed int `json:"failed"` - Errors []string `json:"errors,omitempty"` -} - -// sendQREmailsHandler sends personalized QR code emails to all accepted hackers -// -// @Summary Send QR code emails (Super Admin) -// @Description Generates and sends personalized QR code emails to all accepted hackers. QR encodes the user UUID for check-in scanning. -// @Tags superadmin -// @Produce json -// @Success 200 {object} SendQREmailsResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/emails/qr [post] -func (app *application) sendQREmailsHandler(w http.ResponseWriter, r *http.Request) { - users, err := app.store.Application.GetEmailsByStatus(r.Context(), store.StatusAccepted) - if err != nil { - app.internalServerError(w, r, err) - return - } - - if len(users) == 0 { - if err := app.jsonResponse(w, http.StatusOK, SendQREmailsResponse{}); err != nil { - app.internalServerError(w, r, err) - } - return - } - - var ( - sentCount atomic.Int64 - failedCount atomic.Int64 - mu sync.Mutex - errMessages []string - wg sync.WaitGroup - semaphore = make(chan struct{}, 10) - ) - - for _, u := range users { - wg.Add(1) - go func() { - defer wg.Done() - semaphore <- struct{}{} - defer func() { <-semaphore }() - - name := "Hacker" - if u.FirstName != nil && *u.FirstName != "" { - name = *u.FirstName - } - - if err := app.mailer.SendQREmail(u.Email, name, u.UserID); err != nil { - failedCount.Add(1) - app.logger.Errorw("failed to send QR email", - "email", u.Email, "error", err) - mu.Lock() - errMessages = append(errMessages, fmt.Sprintf("%s: %s", u.Email, err.Error())) - mu.Unlock() - return - } - sentCount.Add(1) - }() - } - - wg.Wait() - - response := SendQREmailsResponse{ - Total: len(users), - Sent: int(sentCount.Load()), - Failed: int(failedCount.Load()), - Errors: errMessages, - } - - if err := app.jsonResponse(w, http.StatusOK, response); err != nil { - app.internalServerError(w, r, err) - } -} diff --git a/cmd/api/emails_test.go b/cmd/api/emails_test.go deleted file mode 100644 index 5ee302d2..00000000 --- a/cmd/api/emails_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "net/http" - "testing" - - "github.com/hackutd/portal/internal/mailer" - "github.com/hackutd/portal/internal/store" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSendQREmails(t *testing.T) { - aliceName := "Alice" - users := []store.UserEmailInfo{ - {UserID: "uid-alice", Email: "alice@test.com", FirstName: &aliceName}, - {UserID: "uid-bob", Email: "bob@test.com", FirstName: nil}, - } - - t.Run("sends to all accepted hackers", func(t *testing.T) { - app := newTestApplication(t) - mockApps := app.store.Application.(*store.MockApplicationStore) - mockMailer := app.mailer.(*mailer.MockClient) - - mockApps.On("GetEmailsByStatus", store.StatusAccepted).Return(users, nil).Once() - mockMailer.On("SendQREmail", "alice@test.com", "Alice", "uid-alice").Return(nil).Once() - mockMailer.On("SendQREmail", "bob@test.com", "Hacker", "uid-bob").Return(nil).Once() - - req, err := http.NewRequest(http.MethodPost, "/", nil) - require.NoError(t, err) - req = setUserContext(req, newSuperAdminUser()) - - rr := executeRequest(req, http.HandlerFunc(app.sendQREmailsHandler)) - checkResponseCode(t, http.StatusOK, rr.Code) - - var body struct { - Data SendQREmailsResponse `json:"data"` - } - err = json.NewDecoder(rr.Body).Decode(&body) - require.NoError(t, err) - assert.Equal(t, 2, body.Data.Total) - assert.Equal(t, 2, body.Data.Sent) - assert.Equal(t, 0, body.Data.Failed) - - mockApps.AssertExpectations(t) - mockMailer.AssertExpectations(t) - }) - - t.Run("empty list returns zero counts", func(t *testing.T) { - app := newTestApplication(t) - mockApps := app.store.Application.(*store.MockApplicationStore) - - mockApps.On("GetEmailsByStatus", store.StatusAccepted).Return([]store.UserEmailInfo{}, nil).Once() - - req, err := http.NewRequest(http.MethodPost, "/", nil) - require.NoError(t, err) - req = setUserContext(req, newSuperAdminUser()) - - rr := executeRequest(req, http.HandlerFunc(app.sendQREmailsHandler)) - checkResponseCode(t, http.StatusOK, rr.Code) - - var body struct { - Data SendQREmailsResponse `json:"data"` - } - err = json.NewDecoder(rr.Body).Decode(&body) - require.NoError(t, err) - assert.Equal(t, 0, body.Data.Total) - - mockApps.AssertExpectations(t) - }) - - t.Run("partial failure reports errors", func(t *testing.T) { - app := newTestApplication(t) - mockApps := app.store.Application.(*store.MockApplicationStore) - mockMailer := app.mailer.(*mailer.MockClient) - - mockApps.On("GetEmailsByStatus", store.StatusAccepted).Return(users, nil).Once() - mockMailer.On("SendQREmail", "alice@test.com", "Alice", "uid-alice").Return(nil).Once() - mockMailer.On("SendQREmail", "bob@test.com", "Hacker", "uid-bob").Return(errors.New("sendgrid error")).Once() - - req, err := http.NewRequest(http.MethodPost, "/", nil) - require.NoError(t, err) - req = setUserContext(req, newSuperAdminUser()) - - rr := executeRequest(req, http.HandlerFunc(app.sendQREmailsHandler)) - checkResponseCode(t, http.StatusOK, rr.Code) - - var body struct { - Data SendQREmailsResponse `json:"data"` - } - err = json.NewDecoder(rr.Body).Decode(&body) - require.NoError(t, err) - assert.Equal(t, 2, body.Data.Total) - assert.Equal(t, 1, body.Data.Sent) - assert.Equal(t, 1, body.Data.Failed) - assert.Len(t, body.Data.Errors, 1) - - mockApps.AssertExpectations(t) - mockMailer.AssertExpectations(t) - }) - - t.Run("store error returns 500", func(t *testing.T) { - app := newTestApplication(t) - mockApps := app.store.Application.(*store.MockApplicationStore) - - mockApps.On("GetEmailsByStatus", store.StatusAccepted).Return(nil, errors.New("db error")).Once() - - req, err := http.NewRequest(http.MethodPost, "/", nil) - require.NoError(t, err) - req = setUserContext(req, newSuperAdminUser()) - - rr := executeRequest(req, http.HandlerFunc(app.sendQREmailsHandler)) - checkResponseCode(t, http.StatusInternalServerError, rr.Code) - - mockApps.AssertExpectations(t) - }) -} - -// Ensure MockClient satisfies mailer.Client at compile time -var _ mailer.Client = (*mailer.MockClient)(nil) diff --git a/docs/docs.go b/docs/docs.go index 9eaab274..e30c220f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -63,6 +63,12 @@ const docTemplate = `{ "description": "Pagination direction: forward (default) or backward", "name": "direction", "in": "query" + }, + { + "type": "string", + "description": "Sort column: created_at (default), accept_votes, reject_votes, waitlist_votes", + "name": "sort_by", + "in": "query" } ], "responses": { @@ -1553,64 +1559,6 @@ const docTemplate = `{ } } }, - "/superadmin/emails/qr": { - "post": { - "security": [ - { - "CookieAuth": [] - } - ], - "description": "Generates and sends personalized QR code emails to all accepted hackers. QR encodes the user UUID for check-in scanning.", - "produces": [ - "application/json" - ], - "tags": [ - "superadmin" - ], - "summary": "Send QR code emails (Super Admin)", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/main.SendQREmailsResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - }, - "403": { - "description": "Forbidden", - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - }, "/superadmin/settings/review-assignment-toggle": { "get": { "security": [ @@ -2312,6 +2260,20 @@ const docTemplate = `{ } } }, + "main.ApplicantInfo": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + } + }, "main.ApplicationResponse": { "type": "object", "properties": { @@ -2503,14 +2465,14 @@ const docTemplate = `{ "main.EmailListResponse": { "type": "object", "properties": { - "count": { - "type": "integer" - }, - "emails": { + "applicants": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/main.ApplicantInfo" } + }, + "count": { + "type": "integer" } } }, @@ -2593,26 +2555,6 @@ const docTemplate = `{ } } }, - "main.SendQREmailsResponse": { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "string" - } - }, - "failed": { - "type": "integer" - }, - "sent": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, "main.SetAIPercentPayload": { "type": "object", "properties": { diff --git a/internal/store/applications.go b/internal/store/applications.go index 68da90d3..a568f374 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -93,16 +93,28 @@ const ( DirectionBackward PaginationDirection = "backward" ) +// ApplicationSortBy defines the column to sort the application list by +type ApplicationSortBy string + +const ( + SortByCreatedAt ApplicationSortBy = "created_at" + SortByAcceptVotes ApplicationSortBy = "accept_votes" + SortByRejectVotes ApplicationSortBy = "reject_votes" + SortByWaitlistVotes ApplicationSortBy = "waitlist_votes" +) + // ApplicationCursor represents pagination cursor type ApplicationCursor struct { CreatedAt time.Time `json:"c"` ID string `json:"i"` + SortVal *int `json:"v,omitempty"` // used for vote-column sorting } // ApplicationListFilters for query filtering type ApplicationListFilters struct { Status *ApplicationStatus Search *string + SortBy ApplicationSortBy } // ApplicationListItem is a lightweight view for admin listing @@ -151,13 +163,20 @@ type ApplicationStats struct { AcceptanceRate float64 `json:"acceptance_rate"` } -// EncodeCursor creates a base64-encoded cursor string +// EncodeCursor creates a base64-encoded cursor string for created_at sorting func EncodeCursor(createdAt time.Time, id string) string { cursor := ApplicationCursor{CreatedAt: createdAt, ID: id} data, _ := json.Marshal(cursor) return base64.URLEncoding.EncodeToString(data) } +// EncodeSortCursor creates a base64-encoded cursor string for vote-column sorting +func EncodeSortCursor(sortVal int, id string) string { + cursor := ApplicationCursor{ID: id, SortVal: &sortVal} + data, _ := json.Marshal(cursor) + return base64.URLEncoding.EncodeToString(data) +} + // DecodeCursor parses a base64-encoded cursor string func DecodeCursor(encoded string) (*ApplicationCursor, error) { data, err := base64.URLEncoding.DecodeString(encoded) @@ -168,8 +187,12 @@ func DecodeCursor(encoded string) (*ApplicationCursor, error) { if err := json.Unmarshal(data, &cursor); err != nil { return nil, fmt.Errorf("invalid cursor format") } - if cursor.ID == "" || cursor.CreatedAt.IsZero() { - return nil, fmt.Errorf("invalid cursor: missing fields") + // Valid if either (CreatedAt + ID) or (SortVal + ID) + if cursor.ID == "" { + return nil, fmt.Errorf("invalid cursor: missing id") + } + if cursor.CreatedAt.IsZero() && cursor.SortVal == nil { + return nil, fmt.Errorf("invalid cursor: missing sort value") } return &cursor, nil } @@ -423,7 +446,46 @@ func (s *ApplicationsStore) Submit(ctx context.Context, app *Application) error return nil } -// Cursor pagination for applciations +// sortColumnName returns the SQL column name for a given sort key. +// Only whitelisted values are accepted to prevent SQL injection. +func sortColumnName(sortBy ApplicationSortBy) string { + switch sortBy { + case SortByAcceptVotes: + return "a.accept_votes" + case SortByRejectVotes: + return "a.reject_votes" + case SortByWaitlistVotes: + return "a.waitlist_votes" + default: + return "a.created_at" + } +} + +// isVoteSort returns true if sorting by a vote column instead of created_at +func isVoteSort(sortBy ApplicationSortBy) bool { + switch sortBy { + case SortByAcceptVotes, SortByRejectVotes, SortByWaitlistVotes: + return true + default: + return false + } +} + +// getVoteVal extracts the vote count from an ApplicationListItem based on the sort column +func getVoteVal(item ApplicationListItem, sortBy ApplicationSortBy) int { + switch sortBy { + case SortByAcceptVotes: + return item.AcceptVotes + case SortByRejectVotes: + return item.RejectVotes + case SortByWaitlistVotes: + return item.WaitlistVotes + default: + return 0 + } +} + +// Cursor pagination for applications func (s *ApplicationsStore) List( ctx context.Context, filters ApplicationListFilters, @@ -442,72 +504,104 @@ func (s *ApplicationsStore) List( limit = 100 } - var cursorTime *time.Time - var cursorID *string - if cursor != nil { - cursorTime = &cursor.CreatedAt - cursorID = &cursor.ID + sortBy := filters.SortBy + if sortBy == "" { + sortBy = SortByCreatedAt } + voteSort := isVoteSort(sortBy) + col := sortColumnName(sortBy) - // Forward query (default): ORDER BY created_at DESC, id DESC - // Backward query: ORDER BY created_at ASC, id ASC (then reverse results) var searchParam *string if filters.Search != nil { searchParam = filters.Search } - var query string - if direction == DirectionBackward && cursor != nil { - query = ` - SELECT a.id, a.user_id, u.email, a.status, - a.first_name, a.last_name, a.phone_e164, a.age, - a.country_of_residence, a.gender, - a.university, a.major, a.level_of_study, - a.hackathons_attended_count, - a.submitted_at, a.created_at, a.updated_at, - a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent - FROM applications a - INNER JOIN users u ON a.user_id = u.id - WHERE ($1::application_status IS NULL OR a.status = $1) - AND (a.created_at, a.id) > ($2, $3::uuid) - AND ($5::text IS NULL OR ( - u.email ILIKE '%' || $5 || '%' - OR a.first_name ILIKE '%' || $5 || '%' - OR a.last_name ILIKE '%' || $5 || '%' - )) - ORDER BY a.created_at ASC, a.id ASC - LIMIT $4` - } else { - query = ` - SELECT a.id, a.user_id, u.email, a.status, - a.first_name, a.last_name, a.phone_e164, a.age, - a.country_of_residence, a.gender, - a.university, a.major, a.level_of_study, - a.hackathons_attended_count, - a.submitted_at, a.created_at, a.updated_at, - a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent - FROM applications a - INNER JOIN users u ON a.user_id = u.id - WHERE ($1::application_status IS NULL OR a.status = $1) - AND ($2::timestamptz IS NULL OR (a.created_at, a.id) < ($2, $3::uuid)) - AND ($5::text IS NULL OR ( - u.email ILIKE '%' || $5 || '%' - OR a.first_name ILIKE '%' || $5 || '%' - OR a.last_name ILIKE '%' || $5 || '%' - )) - ORDER BY a.created_at DESC, a.id DESC - LIMIT $4` - } + selectCols := ` + SELECT a.id, a.user_id, u.email, a.status, + a.first_name, a.last_name, a.phone_e164, a.age, + a.country_of_residence, a.gender, + a.university, a.major, a.level_of_study, + a.hackathons_attended_count, + a.submitted_at, a.created_at, a.updated_at, + a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent + FROM applications a + INNER JOIN users u ON a.user_id = u.id` + + searchClause := `AND ($5::text IS NULL OR ( + u.email ILIKE '%' || $5 || '%' + OR a.first_name ILIKE '%' || $5 || '%' + OR a.last_name ILIKE '%' || $5 || '%' + ))` // Fetch limit+1 to determine hasMore queryLimit := limit + 1 - var statusParam interface{} + var statusParam any if filters.Status != nil { statusParam = *filters.Status } - rows, err := s.db.QueryContext(ctx, query, statusParam, cursorTime, cursorID, queryLimit, searchParam) + var rows *sql.Rows + var err error + + if voteSort { + // Vote-column sorting: cursor uses (sort_val, id) + var cursorVal *int + var cursorID *string + if cursor != nil { + cursorVal = cursor.SortVal + cursorID = &cursor.ID + } + + var query string + if direction == DirectionBackward && cursor != nil { + // Backward: fetch items AFTER cursor in ASC order, then reverse + query = fmt.Sprintf(`%s + WHERE ($1::application_status IS NULL OR a.status = $1) + AND ($2::int IS NULL OR (%s, a.id) > ($2, $3::uuid)) + %s + ORDER BY %s ASC, a.id ASC + LIMIT $4`, selectCols, col, searchClause, col) + } else { + // Forward (default): DESC order + query = fmt.Sprintf(`%s + WHERE ($1::application_status IS NULL OR a.status = $1) + AND ($2::int IS NULL OR (%s, a.id) < ($2, $3::uuid)) + %s + ORDER BY %s DESC, a.id DESC + LIMIT $4`, selectCols, col, searchClause, col) + } + + rows, err = s.db.QueryContext(ctx, query, statusParam, cursorVal, cursorID, queryLimit, searchParam) + } else { + // Default created_at sorting + var cursorTime *time.Time + var cursorID *string + if cursor != nil { + cursorTime = &cursor.CreatedAt + cursorID = &cursor.ID + } + + var query string + if direction == DirectionBackward && cursor != nil { + query = fmt.Sprintf(`%s + WHERE ($1::application_status IS NULL OR a.status = $1) + AND (a.created_at, a.id) > ($2, $3::uuid) + %s + ORDER BY a.created_at ASC, a.id ASC + LIMIT $4`, selectCols, searchClause) + } else { + query = fmt.Sprintf(`%s + WHERE ($1::application_status IS NULL OR a.status = $1) + AND ($2::timestamptz IS NULL OR (a.created_at, a.id) < ($2, $3::uuid)) + %s + ORDER BY a.created_at DESC, a.id DESC + LIMIT $4`, selectCols, searchClause) + } + + rows, err = s.db.QueryContext(ctx, query, statusParam, cursorTime, cursorID, queryLimit, searchParam) + } + if err != nil { return nil, err } @@ -553,30 +647,26 @@ func (s *ApplicationsStore) List( // Generate cursors if len(items) > 0 { - // Going Backwards if direction == DirectionBackward { lastItem := items[len(items)-1] - nc := EncodeCursor(lastItem.CreatedAt, lastItem.ID) + nc := s.encodeCursorForItem(lastItem, sortBy, voteSort) result.NextCursor = &nc - // Prev cursor only if there are more items if hasMore { firstItem := items[0] - pc := EncodeCursor(firstItem.CreatedAt, firstItem.ID) + pc := s.encodeCursorForItem(firstItem, sortBy, voteSort) result.PrevCursor = &pc } } else { - // Next cursor (default) if hasMore { lastItem := items[len(items)-1] - nc := EncodeCursor(lastItem.CreatedAt, lastItem.ID) + nc := s.encodeCursorForItem(lastItem, sortBy, voteSort) result.NextCursor = &nc } - // Prev cursor if cursor != nil { firstItem := items[0] - pc := EncodeCursor(firstItem.CreatedAt, firstItem.ID) + pc := s.encodeCursorForItem(firstItem, sortBy, voteSort) result.PrevCursor = &pc } } @@ -585,6 +675,13 @@ func (s *ApplicationsStore) List( return result, nil } +func (s *ApplicationsStore) encodeCursorForItem(item ApplicationListItem, sortBy ApplicationSortBy, voteSort bool) string { + if voteSort { + return EncodeSortCursor(getVoteVal(item, sortBy), item.ID) + } + return EncodeCursor(item.CreatedAt, item.ID) +} + func (s *ApplicationsStore) SetStatus(ctx context.Context, id string, status ApplicationStatus) (*Application, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() @@ -672,6 +769,7 @@ type UserEmailInfo struct { UserID string `json:"user_id"` Email string `json:"email"` FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` } func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) { @@ -679,7 +777,7 @@ func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status Applic defer cancel() query := ` - SELECT a.user_id, u.email, a.first_name + SELECT a.user_id, u.email, a.first_name, a.last_name FROM applications a INNER JOIN users u ON a.user_id = u.id WHERE a.status = $1 @@ -694,7 +792,7 @@ func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status Applic var users []UserEmailInfo for rows.Next() { var u UserEmailInfo - if err := rows.Scan(&u.UserID, &u.Email, &u.FirstName); err != nil { + if err := rows.Scan(&u.UserID, &u.Email, &u.FirstName, &u.LastName); err != nil { return nil, err } users = append(users, u)