From 6512e4ea2affa9de0836154e8cecf231a1128311 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 27 Feb 2026 09:48:06 +0100 Subject: [PATCH] feat(02-02): implement full RankingDashboard component - Replace stub with complete drag-and-drop ranked project list (DASH-01, DASH-02) - localOrder in useState with useRef init guard prevents snap-back (DASH-03) - Per-category DndContext (STARTUP / BUSINESS_CONCEPT) with SortableProjectRow - AI-order rows show dark-blue rank badge; admin-reordered show amber '(override)' badge - Sheet panel lazy-loads trpc.project.getFullDetail on row click (DASH-04) - Per-juror evaluation breakdown with score, binary decision, feedback text - 'Run Ranking' button in header triggers triggerAutoRank mutation - Empty categories show placeholder message (no empty drag zone) - Zero TypeScript errors; build passes --- .../admin/round/ranking-dashboard.tsx | 489 +++++++++++++++++- 1 file changed, 486 insertions(+), 3 deletions(-) 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} +
+
+ + ) +}