'use client' import React, { 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { GripVertical, BarChart3, Loader2, RefreshCw, Trophy, ExternalLink, } from 'lucide-react' import type { RankedProjectEntry } from '@/server/services/ai-ranking' // ─── Types ──────────────────────────────────────────────────────────────────── type RankingDashboardProps = { competitionId: string roundId: string } type ProjectInfo = { title: string teamName: string | null country: string | null } type SortableProjectRowProps = { projectId: string currentRank: number entry: RankedProjectEntry | undefined projectInfo: ProjectInfo | undefined onSelect: () => void isSelected: boolean } // ─── Sub-component: SortableProjectRow ──────────────────────────────────────── function SortableProjectRow({ projectId, currentRank, entry, projectInfo, 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 (
{/* Drag handle */} {/* Rank badge */} {isOverridden ? ( #{currentRank} (override) ) : ( #{currentRank} )} {/* Project info */}

{projectInfo?.title ?? `Project …${projectId.slice(-6)}`}

{projectInfo?.teamName && (

{projectInfo.teamName} {projectInfo.country ? ` · ${projectInfo.country}` : ''}

)}
{/* Stats */} {entry && (
{Math.round(entry.compositeScore * 100)}% {entry.avgGlobalScore !== null && ( Avg {entry.avgGlobalScore.toFixed(1)} )} Yes {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) const pendingReorderCount = useRef(0) // ─── Advance dialog state ───────────────────────────────────────────────── const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false) const [topNStartup, setTopNStartup] = useState(3) const [topNConceptual, setTopNConceptual] = useState(3) const [includeReject, setIncludeReject] = useState(false) // ─── Expandable review state ────────────────────────────────────────────── const [expandedReviews, setExpandedReviews] = useState>(new Set()) // ─── 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: projectStates } = trpc.roundEngine.getProjectStates.useQuery( { roundId }, ) const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery( { id: selectedProjectId! }, { enabled: !!selectedProjectId }, ) const { data: roundData } = trpc.round.getById.useQuery({ id: roundId }) // ─── tRPC mutations ─────────────────────────────────────────────────────── const utils = trpc.useUtils() const saveReorderMutation = trpc.ranking.saveReorder.useMutation({ onMutate: () => { pendingReorderCount.current++ }, onSettled: () => { pendingReorderCount.current-- }, 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), }) const advanceMutation = trpc.round.advanceProjects.useMutation({ onSuccess: (data) => { toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`) void utils.roundEngine.getProjectStates.invalidate({ roundId }) setAdvanceDialogOpen(false) }, onError: (err) => toast.error(err.message), }) const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({ onSuccess: (data) => { // MEMORY.md: use .length, not direct value comparison toast.success(`Rejected ${data.succeeded.length} project(s)`) if (data.failed.length > 0) { toast.warning(`${data.failed.length} project(s) could not be rejected`) } void utils.roundEngine.getProjectStates.invalidate({ roundId }) setAdvanceDialogOpen(false) }, onError: (err) => toast.error(err.message), }) // ─── evalConfig (advancement counts from round config) ──────────────────── const evalConfig = useMemo(() => { if (!roundData?.configJson) return null try { const config = roundData.configJson as Record const advConfig = config.advancementConfig as Record | undefined return { startupAdvanceCount: (advConfig?.startupCount ?? config.startupAdvanceCount ?? 0) as number, conceptAdvanceCount: (advConfig?.conceptCount ?? config.conceptAdvanceCount ?? 0) as number, } } catch { return null } }, [roundData]) // ─── 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]) // ─── projectInfoMap (O(1) lookup by projectId) ──────────────────────────── const projectInfoMap = useMemo(() => { const map = new Map() if (!projectStates) return map for (const ps of projectStates) { map.set(ps.project.id, { title: ps.project.title, teamName: ps.project.teamName, country: ps.project.country, }) } return map }, [projectStates]) // ─── 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]) // ─── sync advance dialog defaults from config ──────────────────────────── useEffect(() => { if (evalConfig) { if (evalConfig.startupAdvanceCount > 0) setTopNStartup(evalConfig.startupAdvanceCount) if (evalConfig.conceptAdvanceCount > 0) setTopNConceptual(evalConfig.conceptAdvanceCount) } }, [evalConfig]) // ─── 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 } }) } // ─── handleAdvance ──────────────────────────────────────────────────────── function handleAdvance() { const advanceIds = [ ...localOrder.STARTUP.slice(0, topNStartup), ...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual), ] const advanceSet = new Set(advanceIds) advanceMutation.mutate({ roundId, projectIds: advanceIds }) if (includeReject) { const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter( (id) => !advanceSet.has(id), ) if (rejectIds.length > 0) { batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' }) } } } // ─── 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]} {evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 && ( (Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance) )} {localOrder[category].length === 0 ? (

No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.

) : ( handleDragEnd(category, event)} >
{localOrder[category].map((projectId, index) => { const advanceCount = category === 'STARTUP' ? (evalConfig?.startupAdvanceCount ?? 0) : (evalConfig?.conceptAdvanceCount ?? 0) const isAdvancing = advanceCount > 0 && index < advanceCount const isCutoffRow = advanceCount > 0 && index === advanceCount - 1 return ( setSelectedProjectId(projectId)} isSelected={selectedProjectId === projectId} /> {isCutoffRow && (
Advancement cutoff — Top {advanceCount}
)} ) })}
)} ))}
{/* Advance Top N dialog */} Advance Top Projects Select how many top-ranked projects to advance to the next round per category. Projects are advanced in the order shown in the ranking list.
{/* Top N for STARTUP */} {localOrder.STARTUP.length > 0 && (
setTopNStartup( Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)), ) } className="w-24" /> of {localOrder.STARTUP.length}
)} {/* Top N for BUSINESS_CONCEPT */} {localOrder.BUSINESS_CONCEPT.length > 0 && (
setTopNConceptual( Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)), ) } className="w-24" /> of {localOrder.BUSINESS_CONCEPT.length}
)} {/* Optional: also batch-reject non-advanced */}
setIncludeReject(e.target.checked)} className="h-4 w-4 accent-[#de0f1e]" />
{/* Preview */}

Advancing: {topNStartup + topNConceptual} projects

{includeReject && (

Rejecting:{' '} {localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '} projects

)}
{/* Side panel Sheet */} { if (!open) setSelectedProjectId(null) }} > {projectDetail?.project.title ?? 'Project Details'} {selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''} {selectedProjectId && ( View Project Page )} {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) => { const isExpanded = expandedReviews.has(a.id) return (
setExpandedReviews(prev => { const next = new Set(prev) next.has(a.id) ? next.delete(a.id) : next.add(a.id) return next })} >
{a.user?.name ?? a.user?.email ?? 'Unknown'}
{a.evaluation?.binaryDecision != null && ( {a.evaluation.binaryDecision ? 'Yes' : 'No'} )} Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}
{isExpanded && a.evaluation?.feedbackText && (

{a.evaluation.feedbackText}

)}
) })}
) })()}
) : null}
) }