diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index bfe127d..a125109 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -43,6 +43,13 @@ import { } 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, @@ -50,6 +57,9 @@ import { RefreshCw, Trophy, ExternalLink, + ChevronDown, + Settings2, + Download, } from 'lucide-react' import type { RankedProjectEntry } from '@/server/services/ai-ranking' @@ -231,13 +241,24 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran // ─── 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), @@ -273,6 +294,10 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran { roundId }, ) + const { data: evalForm } = trpc.evaluation.getStageForm.useQuery( + { roundId }, + ) + // ─── tRPC mutations ─────────────────────────────────────────────────────── const utils = trpc.useUtils() @@ -283,6 +308,14 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran // 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({ @@ -372,6 +405,34 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran } }, [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) { @@ -400,12 +461,36 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran }) } + // ─── 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() { - const advanceIds = [ - ...localOrder.STARTUP.slice(0, topNStartup), - ...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual), - ] + 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 }) @@ -420,6 +505,44 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran } } + // ─── 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 ( @@ -510,6 +633,19 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran )}
+