diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx
index 43a9c96..6aa9f44 100644
--- a/src/components/admin/round/ranking-dashboard.tsx
+++ b/src/components/admin/round/ranking-dashboard.tsx
@@ -1,14 +1,497 @@
'use client'
+import { useState, useEffect, useRef, useMemo } from 'react'
+import { trpc } from '@/lib/trpc/client'
+import { toast } from 'sonner'
+import { cn } from '@/lib/utils'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { AnimatePresence, motion } from 'motion/react'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from '@/components/ui/sheet'
+import {
+ GripVertical,
+ BarChart3,
+ Loader2,
+ RefreshCw,
+} from 'lucide-react'
+import type { RankedProjectEntry } from '@/server/services/ai-ranking'
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
type RankingDashboardProps = {
competitionId: string
roundId: string
}
-export function RankingDashboard({ competitionId: _competitionId, roundId: _roundId }: RankingDashboardProps) {
+type SortableProjectRowProps = {
+ projectId: string
+ currentRank: number
+ entry: RankedProjectEntry | undefined
+ onSelect: () => void
+ isSelected: boolean
+}
+
+// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
+
+function SortableProjectRow({
+ projectId,
+ currentRank,
+ entry,
+ onSelect,
+ isSelected,
+}: SortableProjectRowProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: projectId })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ }
+
+ // isOverridden: current position differs from AI-assigned rank
+ const isOverridden = entry !== undefined && currentRank !== entry.rank
+
return (
-
- Ranking dashboard coming soon...
+
+ {/* Drag handle */}
+
+
+ {/* Rank badge */}
+ {isOverridden ? (
+
+ #{currentRank} (override)
+
+ ) : (
+
+ #{currentRank}
+
+ )}
+
+ {/* Project identifier */}
+
+
+ Project …{projectId.slice(-6)}
+
+
+
+ {/* Stats */}
+ {entry && (
+
+
+
+ {Math.round(entry.compositeScore * 100)}%
+
+ {entry.avgGlobalScore !== null && (
+
+ Avg {entry.avgGlobalScore.toFixed(1)}
+
+ )}
+
+ Pass {Math.round(entry.passRate * 100)}%
+
+
+ {entry.evaluatorCount} juror{entry.evaluatorCount !== 1 ? 's' : ''}
+
+
+ )}
)
}
+
+// ─── Main component ────────────────────────────────────────────────────────────
+
+export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
+ // ─── State ────────────────────────────────────────────────────────────────
+ const [selectedProjectId, setSelectedProjectId] = useState
(null)
+ const [localOrder, setLocalOrder] = useState>({
+ STARTUP: [],
+ BUSINESS_CONCEPT: [],
+ })
+ const initialized = useRef(false)
+
+ // ─── Sensors ──────────────────────────────────────────────────────────────
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ )
+
+ // ─── tRPC queries ─────────────────────────────────────────────────────────
+ const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
+ { roundId },
+ { refetchInterval: 30_000 },
+ )
+
+ const latestSnapshotId = snapshots?.[0]?.id ?? null
+ const latestSnapshot = snapshots?.[0] ?? null
+
+ const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
+ { snapshotId: latestSnapshotId! },
+ { enabled: !!latestSnapshotId },
+ )
+
+ const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
+ { id: selectedProjectId! },
+ { enabled: !!selectedProjectId },
+ )
+
+ // ─── tRPC mutations ───────────────────────────────────────────────────────
+ const utils = trpc.useUtils()
+
+ const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
+ onError: (err) => toast.error(`Failed to save order: ${err.message}`),
+ // Do NOT invalidate getSnapshot — would reset localOrder
+ })
+
+ const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
+ onSuccess: () => {
+ toast.success('Ranking complete. Reload to see results.')
+ initialized.current = false // allow re-init on next snapshot load
+ void utils.ranking.listSnapshots.invalidate({ roundId })
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
+ const rankingMap = useMemo(() => {
+ const map = new Map()
+ if (!snapshot) return map
+ const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
+ const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
+ ;[...startup, ...concept].forEach((entry) => map.set(entry.projectId, entry))
+ return map
+ }, [snapshot])
+
+ // ─── localOrder init (once, with useRef guard) ────────────────────────────
+ useEffect(() => {
+ if (!initialized.current && snapshot) {
+ const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
+ const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
+ setLocalOrder({
+ STARTUP: startup.map((r) => r.projectId),
+ BUSINESS_CONCEPT: concept.map((r) => r.projectId),
+ })
+ initialized.current = true
+ }
+ }, [snapshot])
+
+ // ─── handleDragEnd ────────────────────────────────────────────────────────
+ function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+ setLocalOrder((prev) => {
+ const ids = prev[category]
+ const newIds = arrayMove(
+ ids,
+ ids.indexOf(active.id as string),
+ ids.indexOf(over.id as string),
+ )
+ saveReorderMutation.mutate({
+ snapshotId: latestSnapshotId!,
+ category,
+ orderedProjectIds: newIds,
+ })
+ return { ...prev, [category]: newIds }
+ })
+ }
+
+ // ─── Loading state ────────────────────────────────────────────────────────
+ if (snapshotsLoading || snapshotLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ // ─── Empty state ──────────────────────────────────────────────────────────
+ if (!latestSnapshotId) {
+ return (
+
+
+
+
+
No ranking available yet
+
+ Run ranking from the Config tab to generate results, or trigger it now.
+
+
+
+
+
+ )
+ }
+
+ // ─── Main content ─────────────────────────────────────────────────────────
+ const categoryLabels: Record<'STARTUP' | 'BUSINESS_CONCEPT', string> = {
+ STARTUP: 'Startups',
+ BUSINESS_CONCEPT: 'Business Concepts',
+ }
+
+ return (
+ <>
+
+ {/* Header card */}
+
+
+
+ Latest Ranking Snapshot
+ {latestSnapshot && (
+
+
+ Created{' '}
+ {new Date(latestSnapshot.createdAt).toLocaleString(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })}
+ {latestSnapshot.triggeredBy?.name && ` by ${latestSnapshot.triggeredBy.name}`}
+ {' · '}
+ {latestSnapshot.triggerType}
+
+ {latestSnapshot.criteriaText && (
+
+ Criteria: {latestSnapshot.criteriaText.slice(0, 120)}
+ {latestSnapshot.criteriaText.length > 120 ? '…' : ''}
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* Per-category sections */}
+ {(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
+
+
+
+ {categoryLabels[category]}
+
+
+
+ {localOrder[category].length === 0 ? (
+
+ No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
+
+ ) : (
+ handleDragEnd(category, event)}
+ >
+
+
+
+ {localOrder[category].map((projectId, index) => (
+
+ setSelectedProjectId(projectId)}
+ isSelected={selectedProjectId === projectId}
+ />
+
+ ))}
+
+
+
+
+ )}
+
+
+ ))}
+
+
+ {/* Side panel Sheet */}
+ {
+ if (!open) setSelectedProjectId(null)
+ }}
+ >
+
+
+ {projectDetail?.project.title ?? 'Project Details'}
+
+ {selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
+
+
+
+ {detailLoading ? (
+
+
+
+
+
+ ) : projectDetail ? (
+
+ {/* Stats summary */}
+ {projectDetail.stats && (
+
+
+
Avg Score
+
+ {projectDetail.stats.averageGlobalScore?.toFixed(1) ?? '—'}
+
+
+
+
Pass Rate
+
+ {projectDetail.stats.totalEvaluations > 0
+ ? `${Math.round((projectDetail.stats.yesVotes / projectDetail.stats.totalEvaluations) * 100)}%`
+ : '—'}
+
+
+
+
Evaluators
+
+ {projectDetail.stats.totalEvaluations}
+
+
+
+ )}
+
+ {/* Per-juror evaluations */}
+
+
Juror Evaluations
+ {(() => {
+ const submitted = projectDetail.assignments.filter(
+ (a) => a.evaluation?.status === 'SUBMITTED' && a.round.id === roundId,
+ )
+ if (submitted.length === 0) {
+ return (
+
+ No submitted evaluations for this round.
+
+ )
+ }
+ return (
+
+ {submitted.map((a) => (
+
+
+
{a.user.name ?? a.user.email}
+
+ {a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
+
+ Score: {a.evaluation.globalScore.toFixed(1)}
+
+ )}
+ {a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
+
+ {a.evaluation.binaryDecision ? 'Yes' : 'No'}
+
+ )}
+
+
+ {a.evaluation?.feedbackText && (
+
+ {a.evaluation.feedbackText}
+
+ )}
+
+ ))}
+
+ )
+ })()}
+
+
+ ) : null}
+
+
+ >
+ )
+}