'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 { Textarea } from '@/components/ui/textarea' import { Slider } from '@/components/ui/slider' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { GripVertical, BarChart3, Loader2, RefreshCw, Trophy, ExternalLink, ChevronDown, Settings2, Download, } 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 JurorScore = { jurorName: string globalScore: number | null decision: boolean | null } type SortableProjectRowProps = { projectId: string currentRank: number entry: RankedProjectEntry | undefined projectInfo: ProjectInfo | undefined jurorScores: JurorScore[] | undefined onSelect: () => void isSelected: boolean } // ─── Sub-component: SortableProjectRow ──────────────────────────────────────── function SortableProjectRow({ projectId, currentRank, entry, projectInfo, jurorScores, 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 // Compute yes count from juror scores const yesCount = jurorScores?.filter((j) => j.decision === true).length ?? 0 const totalJurors = jurorScores?.length ?? entry?.evaluatorCount ?? 0 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}` : ''}

)}
{/* Juror scores + advance decision */}
{/* Individual juror score pills */} {jurorScores && jurorScores.length > 0 ? (
`${j.jurorName}: ${j.globalScore ?? '—'}/10`).join('\n')}> {jurorScores.map((j, i) => ( = 8 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : j.globalScore != null && j.globalScore >= 6 ? 'bg-blue-50 text-blue-700 border-blue-200' : j.globalScore != null && j.globalScore >= 4 ? 'bg-amber-50 text-amber-700 border-amber-200' : 'bg-red-50 text-red-700 border-red-200', )} title={`${j.jurorName}: ${j.globalScore ?? '—'}/10`} > {j.globalScore ?? '—'} ))}
) : entry?.avgGlobalScore !== null && entry?.avgGlobalScore !== undefined ? ( Avg {entry.avgGlobalScore.toFixed(1)} ) : null} {/* Average score */} {entry?.avgGlobalScore !== null && entry?.avgGlobalScore !== undefined && jurorScores && jurorScores.length > 1 && ( = {entry.avgGlobalScore.toFixed(1)} )} {/* Advance decision indicator */}
0 ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500', )}> {yesCount > 0 ? ( <>{yesCount}/{totalJurors} Yes ) : ( <>{totalJurors} juror{totalJurors !== 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 [advanceMode, setAdvanceMode] = useState<'top_n' | 'threshold'>('top_n') const [topNStartup, setTopNStartup] = useState(3) const [topNConceptual, setTopNConceptual] = useState(3) const [scoreThreshold, setScoreThreshold] = useState(5) const [includeReject, setIncludeReject] = useState(false) // ─── Export state ────────────────────────────────────────────────────────── const [exportLoading, setExportLoading] = useState(false) // ─── Expandable review state ────────────────────────────────────────────── const [expandedReviews, setExpandedReviews] = useState>(new Set()) // ─── Criteria weights state ──────────────────────────────────────────────── const [weightsOpen, setWeightsOpen] = useState(false) const [localWeights, setLocalWeights] = useState>({}) const [localCriteriaText, setLocalCriteriaText] = useState('') const weightsInitialized = 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: 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 }) const { data: evalScores } = trpc.ranking.roundEvaluationScores.useQuery( { roundId }, ) const { data: evalForm } = trpc.evaluation.getStageForm.useQuery( { 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 updateRoundMutation = trpc.round.update.useMutation({ onSuccess: () => { toast.success('Ranking config saved') void utils.round.getById.invalidate({ id: roundId }) }, onError: (err) => toast.error(`Failed to save: ${err.message}`), }) const [rankingInProgress, setRankingInProgress] = useState(false) const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({ onMutate: () => setRankingInProgress(true), onSuccess: () => { toast.success('Ranking complete!') initialized.current = false // allow re-init on next snapshot load void utils.ranking.listSnapshots.invalidate({ roundId }) void utils.ranking.getSnapshot.invalidate() setRankingInProgress(false) }, onError: (err) => { toast.error(err.message) setRankingInProgress(false) }, }) 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]) // ─── numericCriteria from eval form ───────────────────────────────────── const numericCriteria = useMemo(() => { if (!evalForm?.criteriaJson) return [] return (evalForm.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number | string }>) .filter((c) => !c.type || c.type === 'numeric') }, [evalForm]) // ─── Init local weights + criteriaText from round config ────────────────── useEffect(() => { if (!weightsInitialized.current && roundData?.configJson) { const cfg = roundData.configJson as Record const saved = (cfg.criteriaWeights ?? {}) as Record setLocalWeights(saved) setLocalCriteriaText((cfg.rankingCriteria as string) ?? '') weightsInitialized.current = true } }, [roundData]) // ─── Save weights + criteria text to round config ───────────────────────── const saveRankingConfig = () => { if (!roundData?.configJson) return const cfg = roundData.configJson as Record updateRoundMutation.mutate({ id: roundId, configJson: { ...cfg, criteriaWeights: localWeights, rankingCriteria: localCriteriaText }, }) } // ─── 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 } }) } // ─── Compute threshold-based project IDs ────────────────────────────────── const thresholdAdvanceIds = useMemo(() => { if (advanceMode !== 'threshold') return { ids: [] as string[], startupCount: 0, conceptCount: 0 } const ids: string[] = [] let startupCount = 0 let conceptCount = 0 for (const cat of ['STARTUP', 'BUSINESS_CONCEPT'] as const) { for (const projectId of localOrder[cat]) { const entry = rankingMap.get(projectId) if (entry?.avgGlobalScore != null && entry.avgGlobalScore >= scoreThreshold) { ids.push(projectId) if (cat === 'STARTUP') startupCount++ else conceptCount++ } } } return { ids, startupCount, conceptCount } }, [advanceMode, scoreThreshold, localOrder, rankingMap]) // ─── handleAdvance ──────────────────────────────────────────────────────── function handleAdvance() { let advanceIds: string[] if (advanceMode === 'threshold') { advanceIds = thresholdAdvanceIds.ids } else { 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' }) } } } // ─── handleExport ────────────────────────────────────────────────────────── async function handleExportScores() { setExportLoading(true) try { const result = await utils.export.projectScores.fetch({ roundId }) if (!result.data || result.data.length === 0) { toast.error('No data to export') return } const headers = result.columns const csvRows = [ headers.join(','), ...result.data.map((row: Record) => headers.map((h: string) => { const val = row[h] if (val == null) return '' const str = String(val) return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str }).join(','), ), ] const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `round-scores-${roundId.slice(-8)}.csv` a.click() URL.revokeObjectURL(url) toast.success('CSV exported') } catch (err) { toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`) } finally { setExportLoading(false) } } // ─── Loading state ──────────────────────────────────────────────────────── if (snapshotsLoading || snapshotLoading) { return (
) } // ─── Empty state ────────────────────────────────────────────────────────── if (!latestSnapshotId) { return (
{rankingInProgress ? ( <>

AI ranking in progress…

This may take a minute. You can continue working — results will appear automatically.

) : ( <>

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 ? '…' : ''} )} )}
{/* Ranking Configuration: criteria text + weights */}
Ranking Configuration Criteria text, per-criterion weights, and bias correction
{/* Ranking criteria text */}

Describe how projects should be ranked. The AI will parse this into rules.