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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
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 [topNConceptual, setTopNConceptual] = useState(3)
|
||||||
const [includeReject, setIncludeReject] = useState(false)
|
const [includeReject, setIncludeReject] = useState(false)
|
||||||
|
|
||||||
|
// ─── Expandable review state ──────────────────────────────────────────────
|
||||||
|
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// ─── Sensors ──────────────────────────────────────────────────────────────
|
// ─── Sensors ──────────────────────────────────────────────────────────────
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
@@ -220,6 +224,8 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
{ enabled: !!selectedProjectId },
|
{ enabled: !!selectedProjectId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
|
||||||
|
|
||||||
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
@@ -261,6 +267,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
onError: (err) => toast.error(err.message),
|
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) ──────────────────────────────────────────────
|
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
|
||||||
const rankingMap = useMemo(() => {
|
const rankingMap = useMemo(() => {
|
||||||
const map = new Map<string, RankedProjectEntry>()
|
const map = new Map<string, RankedProjectEntry>()
|
||||||
@@ -298,6 +317,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
}
|
}
|
||||||
}, [snapshot])
|
}, [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 ────────────────────────────────────────────────────────
|
// ─── handleDragEnd ────────────────────────────────────────────────────────
|
||||||
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
|
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
@@ -448,6 +475,11 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{categoryLabels[category]}
|
{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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -467,25 +499,43 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
>
|
>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{localOrder[category].map((projectId, index) => (
|
{localOrder[category].map((projectId, index) => {
|
||||||
<motion.div
|
const advanceCount = category === 'STARTUP'
|
||||||
key={projectId}
|
? (evalConfig?.startupAdvanceCount ?? 0)
|
||||||
layout
|
: (evalConfig?.conceptAdvanceCount ?? 0)
|
||||||
initial={{ opacity: 0 }}
|
const isAdvancing = advanceCount > 0 && index < advanceCount
|
||||||
animate={{ opacity: 1 }}
|
const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
return (
|
||||||
>
|
<React.Fragment key={projectId}>
|
||||||
<SortableProjectRow
|
<motion.div
|
||||||
projectId={projectId}
|
layout
|
||||||
currentRank={index + 1}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
entry={rankingMap.get(projectId)}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
projectInfo={projectInfoMap.get(projectId)}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
onSelect={() => setSelectedProjectId(projectId)}
|
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
|
||||||
isSelected={selectedProjectId === projectId}
|
>
|
||||||
/>
|
<SortableProjectRow
|
||||||
</motion.div>
|
projectId={projectId}
|
||||||
))}
|
currentRank={index + 1}
|
||||||
|
entry={rankingMap.get(projectId)}
|
||||||
|
projectInfo={projectInfoMap.get(projectId)}
|
||||||
|
onSelect={() => setSelectedProjectId(projectId)}
|
||||||
|
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>
|
</div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -617,6 +667,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
||||||
</SheetDescription>
|
</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>
|
</SheetHeader>
|
||||||
|
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
@@ -669,37 +730,40 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{submitted.map((a) => (
|
{submitted.map((a) => {
|
||||||
<div key={a.id} className="rounded-lg border p-3 space-y-2">
|
const isExpanded = expandedReviews.has(a.id)
|
||||||
<div className="flex items-center justify-between gap-2">
|
return (
|
||||||
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
|
<div
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
key={a.id}
|
||||||
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
|
className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
<Badge variant="outline" className="text-xs">
|
onClick={() => setExpandedReviews(prev => {
|
||||||
Score: {a.evaluation.globalScore.toFixed(1)}
|
const next = new Set(prev)
|
||||||
</Badge>
|
next.has(a.id) ? next.delete(a.id) : next.add(a.id)
|
||||||
)}
|
return next
|
||||||
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
|
})}
|
||||||
<Badge
|
>
|
||||||
className={cn(
|
<div className="flex items-center justify-between">
|
||||||
'text-xs',
|
<span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
|
||||||
a.evaluation.binaryDecision
|
<div className="flex items-center gap-2">
|
||||||
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
{a.evaluation?.binaryDecision != null && (
|
||||||
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
<Badge
|
||||||
)}
|
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>
|
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
{a.evaluation?.feedbackText && (
|
)
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
})}
|
||||||
{a.evaluation.feedbackText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user