feat: ranking UI improvements - highlight advancing projects, expandable reviews, view project link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
||||
|
||||
@@ -191,6 +192,9 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
const [topNConceptual, setTopNConceptual] = useState(3)
|
||||
const [includeReject, setIncludeReject] = useState(false)
|
||||
|
||||
// ─── Expandable review state ──────────────────────────────────────────────
|
||||
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
|
||||
|
||||
// ─── Sensors ──────────────────────────────────────────────────────────────
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
{ enabled: !!selectedProjectId },
|
||||
)
|
||||
|
||||
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
|
||||
|
||||
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
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<string, unknown>
|
||||
const advConfig = config.advancementConfig as Record<string, unknown> | 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<string, RankedProjectEntry>()
|
||||
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
}
|
||||
}, [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
|
||||
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{categoryLabels[category]}
|
||||
{evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 && (
|
||||
<span className="ml-2 text-xs font-normal normal-case">
|
||||
(Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance)
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -467,14 +499,21 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
<div className="space-y-2">
|
||||
{localOrder[category].map((projectId, index) => (
|
||||
{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 (
|
||||
<React.Fragment key={projectId}>
|
||||
<motion.div
|
||||
key={projectId}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
|
||||
>
|
||||
<SortableProjectRow
|
||||
projectId={projectId}
|
||||
@@ -485,7 +524,18 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
isSelected={selectedProjectId === projectId}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
{isCutoffRow && (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
|
||||
Advancement cutoff — Top {advanceCount}
|
||||
</span>
|
||||
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
<SheetDescription>
|
||||
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
||||
</SheetDescription>
|
||||
{selectedProjectId && (
|
||||
<a
|
||||
href={`/admin/projects/${selectedProjectId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View Project Page
|
||||
</a>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{detailLoading ? (
|
||||
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{submitted.map((a) => (
|
||||
<div key={a.id} className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Score: {a.evaluation.globalScore.toFixed(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
|
||||
{submitted.map((a) => {
|
||||
const isExpanded = expandedReviews.has(a.id)
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setExpandedReviews(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(a.id) ? next.delete(a.id) : next.add(a.id)
|
||||
return next
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{a.evaluation?.binaryDecision != null && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs',
|
||||
a.evaluation.binaryDecision
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||
)}
|
||||
variant={a.evaluation.binaryDecision ? 'default' : 'destructive'}
|
||||
className={a.evaluation.binaryDecision ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100' : ''}
|
||||
>
|
||||
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{a.evaluation?.feedbackText && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{isExpanded && a.evaluation?.feedbackText && (
|
||||
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
||||
{a.evaluation.feedbackText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user