'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 { 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,
Calculator,
Loader2,
RefreshCw,
Sparkles,
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 & { originalIndex?: number }) | undefined
projectInfo: ProjectInfo | undefined
jurorScores: JurorScore[] | undefined
onSelect: () => void
isSelected: boolean
originalRank: number | undefined // from snapshotOrder — always in sync with localOrder
}
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
function SortableProjectRow({
projectId,
currentRank,
entry,
projectInfo,
jurorScores,
onSelect,
isSelected,
originalRank,
}: SortableProjectRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: projectId })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
// isOverridden: admin drag-reordered this project from its original snapshot position.
// Uses snapshotOrder (set in same effect as localOrder) so they are always in sync.
const isOverridden = originalRank !== undefined && currentRank !== originalRank
// 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-amber-100 text-amber-700'
: 'bg-red-100 text-red-600',
)}>
{totalJurors > 0 ? (
<>{yesCount}/{totalJurors} Yes>
) : (
<>0 jurors>
)}
)
}
// ─── Main component ────────────────────────────────────────────────────────────
export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
// ─── State ────────────────────────────────────────────────────────────────
const [selectedProjectId, setSelectedProjectId] = useState(null)
const [localOrder, setLocalOrder] = useState>({
STARTUP: [],
BUSINESS_CONCEPT: [],
})
// Track the original snapshot order (projectId → 1-based rank) for override detection.
// Updated in the same effect as localOrder so they are always in sync.
const [snapshotOrder, setSnapshotOrder] = useState>({})
const initialized = useRef(false)
const pendingReorderCount = useRef(0)
// ─── 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 [localScoreWeight, setLocalScoreWeight] = useState(5)
const [localPassRateWeight, setLocalPassRateWeight] = useState(5)
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 },
// Poll every 3s so all admins see ranking progress/completion quickly
{ refetchInterval: 3_000 },
)
// Derive ranking-in-progress from server state (visible to ALL admins)
const rankingInProgress = snapshots?.[0]?.status === 'RUNNING'
// Find the latest COMPLETED snapshot (skip RUNNING/FAILED)
const latestCompleted = snapshots?.find((s) => s.status === 'COMPLETED')
const latestSnapshotId = latestCompleted?.id ?? null
const latestSnapshot = latestCompleted ?? 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 triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
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()
},
onError: (err) => {
toast.error(err.message)
void utils.ranking.listSnapshots.invalidate({ roundId })
},
})
// ─── 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 {
advanceMode: (config.advanceMode as string) ?? 'count',
advanceScoreThreshold: (config.advanceScoreThreshold as number) ?? undefined,
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.forEach((entry, i) => map.set(entry.projectId, { ...entry, originalIndex: i + 1 }))
concept.forEach((entry, i) => map.set(entry.projectId, { ...entry, originalIndex: i + 1 }))
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[]
// Deduplicate ranking entries (AI may return duplicates) — keep first occurrence
const dedup = (arr: RankedProjectEntry[]): RankedProjectEntry[] => {
const seen = new Set()
return arr.filter((r) => {
if (seen.has(r.projectId)) return false
seen.add(r.projectId)
return true
})
}
const dedupedStartup = dedup(startup)
const dedupedConcept = dedup(concept)
// Sort by avgGlobalScore descending (the metric displayed to the admin), with
// compositeScore as tiebreaker. This ensures the visible ordering matches the
// numbers on screen AND the threshold cutoff line lands correctly (it checks
// avgGlobalScore, so the list must be sorted by that same metric).
dedupedStartup.sort((a, b) =>
(b.avgGlobalScore ?? 0) - (a.avgGlobalScore ?? 0) || b.compositeScore - a.compositeScore)
dedupedConcept.sort((a, b) =>
(b.avgGlobalScore ?? 0) - (a.avgGlobalScore ?? 0) || b.compositeScore - a.compositeScore)
// Track original order for override detection (same effect = always in sync)
const order: Record = {}
dedupedStartup.forEach((r, i) => { order[r.projectId] = i + 1 })
dedupedConcept.forEach((r, i) => { order[r.projectId] = i + 1 })
setSnapshotOrder(order)
// Apply saved reorders so the ranking persists across all admin sessions.
// reordersJson is append-only — the latest event per category is the current order.
const reorders = (snapshot.reordersJson as Array<{
category: 'STARTUP' | 'BUSINESS_CONCEPT'
orderedProjectIds: string[]
}> | null) ?? []
const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP')
const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT')
// Deduplicate reorder IDs too, and filter out IDs not in the current snapshot
const dedupIds = (ids: string[], validSet: Set): string[] => {
const seen = new Set()
return ids.filter((id) => {
if (seen.has(id) || !validSet.has(id)) return false
seen.add(id)
return true
})
}
const startupIdSet = new Set(dedupedStartup.map((r) => r.projectId))
const conceptIdSet = new Set(dedupedConcept.map((r) => r.projectId))
setLocalOrder({
STARTUP: latestStartupReorder
? dedupIds(latestStartupReorder.orderedProjectIds, startupIdSet)
: dedupedStartup.map((r) => r.projectId),
BUSINESS_CONCEPT: latestConceptReorder
? dedupIds(latestConceptReorder.orderedProjectIds, conceptIdSet)
: dedupedConcept.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) ?? '')
setLocalScoreWeight((cfg.scoreWeight as number) ?? 5)
setLocalPassRateWeight((cfg.passRateWeight as number) ?? 5)
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,
scoreWeight: localScoreWeight,
passRateWeight: localPassRateWeight,
},
})
}
// Derive ranking mode from criteria text
const isFormulaMode = !localCriteriaText.trim()
// ─── 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 }
})
}
// ─── 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 ? (
<>
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 ? '…' : ''}
)}
)}
{/* Advance Top N removed — use Finalization tab instead */}
{/* Ranking Configuration: criteria text + weights */}
Ranking Configuration
Criteria text, per-criterion weights, and bias correction
{/* Score vs Pass Rate weights */}
Control the balance between evaluation scores and yes/no pass rate in the composite ranking
Score Weight
setLocalScoreWeight(v)}
className="flex-1"
/>
{localScoreWeight}
Pass Rate Weight
setLocalPassRateWeight(v)}
className="flex-1"
/>
{localPassRateWeight}
{/* Ranking criteria text (optional — triggers AI mode) */}
Optional: describe special ranking criteria for AI-assisted ranking.
Leave empty for formula-based ranking (faster, no AI cost).
{/* Per-criterion weights */}
{numericCriteria.length > 0 && (
Set relative importance of each evaluation criterion (0 = ignore, 10 = highest priority)
{numericCriteria.map((c) => (
{c.label}
setLocalWeights((prev) => ({ ...prev, [c.id]: v }))}
className="flex-1"
/>
{localWeights[c.id] ?? 1}
))}
)}
Weights are applied when ranking is run. Z-score normalization corrects for juror bias automatically.
{/* Ranking in-progress banner */}
{rankingInProgress && (
Ranking in progress…
This may take a minute. You can continue working — results will appear automatically.
)}
{/* Per-category sections */}
{(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
{categoryLabels[category]}
{evalConfig && evalConfig.advanceMode === 'threshold' && evalConfig.advanceScoreThreshold != null ? (
(Score ≥ {evalConfig.advanceScoreThreshold} advance)
) : evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 ? (
(Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance)
) : null}
{localOrder[category].length === 0 ? (
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
) : (() => {
// Precompute cutoff index so it only shows ONCE
const isThresholdMode = evalConfig?.advanceMode === 'threshold' && evalConfig.advanceScoreThreshold != null
const advanceCount = isThresholdMode ? 0 : (category === 'STARTUP'
? (evalConfig?.startupAdvanceCount ?? 0)
: (evalConfig?.conceptAdvanceCount ?? 0))
const threshold = evalConfig?.advanceScoreThreshold ?? 0
let cutoffIndex = -1
if (isThresholdMode) {
// Find the FIRST project that does NOT meet the threshold — cutoff goes before it.
// Works correctly because localOrder is sorted by avgGlobalScore (the same metric).
const firstFailIdx = localOrder[category].findIndex((id) => {
const e = rankingMap.get(id)
return (e?.avgGlobalScore ?? 0) < threshold
})
if (firstFailIdx === -1) {
// All meet threshold — cutoff after the last one
cutoffIndex = localOrder[category].length - 1
} else if (firstFailIdx > 0) {
cutoffIndex = firstFailIdx - 1
}
} else if (advanceCount > 0) {
cutoffIndex = advanceCount - 1
}
// Check if admin has reordered this category
const reorders = (snapshot?.reordersJson as Array<{
category: 'STARTUP' | 'BUSINESS_CONCEPT'
orderedProjectIds: string[]
}> | null) ?? []
const hasReorders = reorders.some((r) => r.category === category)
return (
handleDragEnd(category, event)}
>
{localOrder[category].map((projectId, index) => {
const entry = rankingMap.get(projectId)
const projectAvg = entry?.avgGlobalScore ?? 0
const isAdvancing = isThresholdMode
? projectAvg >= threshold
: (advanceCount > 0 && index < advanceCount)
const isCutoffRow = cutoffIndex >= 0 && index === cutoffIndex
return (
setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
originalRank={hasReorders ? snapshotOrder[projectId] : undefined}
/>
{isCutoffRow && (
Advancement cutoff — {isThresholdMode ? `Score ≥ ${threshold}` : `Top ${advanceCount}`}
)}
)
})}
)
})()}
))}
{/* Advance dialog removed — use Finalization tab */}
{/* 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}
>
)
}