'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' import { CountryDisplay } from '@/components/shared/country-display' // ─── 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 ? <> · : ''}

)}
{/* 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).