From 050836d5224e26a87f6bda5686a672f8771cc88f Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Mar 2026 22:10:04 +0100 Subject: [PATCH] feat: finalization tab respects ranking overrides, grouped by category - processRoundClose now applies reordersJson drag-reorder overrides when building the evaluation pass set (was ignoring admin reorders) - Finalization tab groups proposed outcomes by category (Startup/Concept) with per-group pass/reject/total counts - Added category filter dropdown alongside the existing outcome filter - Removed legacy "Advance Top N" button and dialog from ranking page (replaced by the finalization workflow) - Fix project edit status defaultValue showing empty placeholder Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/projects/[id]/edit/page.tsx | 2 +- .../admin/round/finalization-tab.tsx | 236 ++++++++++------ .../admin/round/ranking-dashboard.tsx | 263 +----------------- src/server/services/round-finalization.ts | 44 ++- 4 files changed, 182 insertions(+), 363 deletions(-) diff --git a/src/app/(admin)/admin/projects/[id]/edit/page.tsx b/src/app/(admin)/admin/projects/[id]/edit/page.tsx index f64eabc..df1963f 100644 --- a/src/app/(admin)/admin/projects/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/edit/page.tsx @@ -186,7 +186,7 @@ function EditProjectContent({ projectId }: { projectId: string }) { title: '', teamName: '', description: '', - status: undefined, + status: 'SUBMITTED' as const, tags: [], competitionCategory: '', oceanIssue: '', diff --git a/src/components/admin/round/finalization-tab.tsx b/src/components/admin/round/finalization-tab.tsx index bc9eeb5..1a340aa 100644 --- a/src/components/admin/round/finalization-tab.tsx +++ b/src/components/admin/round/finalization-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo } from 'react' +import React, { useState, useMemo } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -76,6 +76,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) const [search, setSearch] = useState('') const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all') + const [filterCategory, setFilterCategory] = useState<'all' | 'STARTUP' | 'BUSINESS_CONCEPT'>('all') const [selectedIds, setSelectedIds] = useState>(new Set()) const [emailSectionOpen, setEmailSectionOpen] = useState(false) const [advancementMessage, setAdvancementMessage] = useState('') @@ -135,9 +136,32 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) (filterOutcome === 'none' && !p.proposedOutcome) || p.proposedOutcome === filterOutcome - return matchesSearch && matchesFilter + const matchesCategory = + filterCategory === 'all' || p.category === filterCategory + + return matchesSearch && matchesFilter && matchesCategory }) - }, [summary, search, filterOutcome]) + }, [summary, search, filterOutcome, filterCategory]) + + // Check if we have multiple categories (to decide whether to group) + const hasMultipleCategories = useMemo(() => { + if (!summary) return false + const cats = new Set(summary.projects.map((p) => p.category).filter(Boolean)) + return cats.size > 1 + }, [summary]) + + // Group filtered projects by category, sorted by rank within each group + const groupedProjects = useMemo(() => { + if (!hasMultipleCategories || filterCategory !== 'all') return null + const groups: { category: string; label: string; projects: typeof filteredProjects }[] = [] + const startups = filteredProjects.filter((p) => p.category === 'STARTUP') + const concepts = filteredProjects.filter((p) => p.category === 'BUSINESS_CONCEPT') + const other = filteredProjects.filter((p) => p.category !== 'STARTUP' && p.category !== 'BUSINESS_CONCEPT') + if (startups.length > 0) groups.push({ category: 'STARTUP', label: 'Startups', projects: startups }) + if (concepts.length > 0) groups.push({ category: 'BUSINESS_CONCEPT', label: 'Business Concepts', projects: concepts }) + if (other.length > 0) groups.push({ category: 'OTHER', label: 'Other', projects: other }) + return groups + }, [filteredProjects, hasMultipleCategories, filterCategory]) // Counts const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0 @@ -163,6 +187,93 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) batchUpdate.mutate({ roundId, outcomes }) } + // Column count for colSpan + const colCount = (summary?.isFinalized ? 0 : 1) + 4 + (summary?.roundType === 'EVALUATION' ? 1 : 0) + 1 + + // Shared row renderer + const renderProjectRow = (project: (typeof filteredProjects)[number]) => ( + + {!summary?.isFinalized && ( + + { + const next = new Set(selectedIds) + if (checked) next.add(project.id) + else next.delete(project.id) + setSelectedIds(next) + }} + aria-label={`Select ${project.title}`} + /> + + )} + +
{project.title}
+ {project.teamName && ( +
{project.teamName}
+ )} + + + {project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'} + + + {project.country ?? '-'} + + + + {project.currentState.replace('_', ' ')} + + + {summary?.roundType === 'EVALUATION' && ( + + {project.evaluationScore != null + ? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})` + : '-'} + + )} + + {summary?.isFinalized ? ( + + {project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'} + + ) : ( + + )} + + + ) + if (isLoading) { return (
@@ -362,12 +473,24 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) - All projects + All outcomes Proposed: Pass Proposed: Reject Undecided + {hasMultipleCategories && ( + + )}
{/* Bulk actions */} @@ -422,95 +545,30 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) - {filteredProjects.map((project) => ( - - {!summary.isFinalized && ( - - { - const next = new Set(selectedIds) - if (checked) next.add(project.id) - else next.delete(project.id) - setSelectedIds(next) - }} - aria-label={`Select ${project.title}`} - /> - - )} - -
{project.title}
- {project.teamName && ( -
{project.teamName}
- )} - - - {project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'} - - - {project.country ?? '-'} - - - - {project.currentState.replace('_', ' ')} - - - {summary.roundType === 'EVALUATION' && ( - - {project.evaluationScore != null - ? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})` - : '-'} - - )} - - {summary.isFinalized ? ( - ( + + + - {project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'} - - ) : ( - - )} - - - ))} + {group.label} + + ({group.projects.filter((p) => p.proposedOutcome === 'PASSED').length} pass, {group.projects.filter((p) => p.proposedOutcome === 'REJECTED').length} reject, {group.projects.length} total) + + + + {group.projects.map((project) => renderProjectRow(project))} + + )) + ) : ( + filteredProjects.map((project) => renderProjectRow(project)) + )} {filteredProjects.length === 0 && ( No projects match your search/filter diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index b619307..27f0604 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -33,14 +33,6 @@ import { SheetTitle, SheetDescription, } from '@/components/ui/sheet' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' @@ -57,7 +49,6 @@ import { Loader2, RefreshCw, Sparkles, - Trophy, ExternalLink, ChevronDown, Settings2, @@ -251,14 +242,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran const initialized = useRef(false) const pendingReorderCount = useRef(0) - // ─── 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) @@ -349,28 +332,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran }, }) - const advanceMutation = trpc.round.advanceProjects.useMutation({ - onSuccess: (data) => { - toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`) - void utils.roundEngine.getProjectStates.invalidate({ roundId }) - setAdvanceDialogOpen(false) - }, - onError: (err) => toast.error(err.message), - }) - - const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({ - onSuccess: (data) => { - // MEMORY.md: use .length, not direct value comparison - toast.success(`Rejected ${data.succeeded.length} project(s)`) - if (data.failed.length > 0) { - toast.warning(`${data.failed.length} project(s) could not be rejected`) - } - void utils.roundEngine.getProjectStates.invalidate({ roundId }) - setAdvanceDialogOpen(false) - }, - onError: (err) => toast.error(err.message), - }) - // ─── evalConfig (advancement counts from round config) ──────────────────── const evalConfig = useMemo(() => { if (!roundData?.configJson) return null @@ -518,14 +479,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran // Derive ranking mode from criteria text const isFormulaMode = !localCriteriaText.trim() - // ─── 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 @@ -546,50 +499,6 @@ 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() { - 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 }) - - if (includeReject) { - const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter( - (id) => !advanceSet.has(id), - ) - if (rejectIds.length > 0) { - batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' }) - } - } - } - // ─── handleExport ────────────────────────────────────────────────────────── async function handleExportScores() { setExportLoading(true) @@ -758,18 +667,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran )} - + {/* Advance Top N removed — use Finalization tab instead */} @@ -1039,164 +937,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran ))} - {/* Advance dialog */} - - - - Advance Projects - - Choose how to select which projects advance to the next round. - - - -
- {/* Mode toggle */} -
- - -
- - {advanceMode === 'top_n' ? ( - <> - {/* Top N for STARTUP */} - {localOrder.STARTUP.length > 0 && ( -
- - - setTopNStartup( - Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)), - ) - } - className="w-24" - /> - of {localOrder.STARTUP.length} -
- )} - - {/* Top N for BUSINESS_CONCEPT */} - {localOrder.BUSINESS_CONCEPT.length > 0 && ( -
- - - setTopNConceptual( - Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)), - ) - } - className="w-24" - /> - of {localOrder.BUSINESS_CONCEPT.length} -
- )} - - ) : ( -
-
- - setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))} - className="w-24" - /> - out of 10 -
-

- All projects with an average global score at or above this threshold will advance, regardless of category. -

-
- )} - - {/* Optional: also batch-reject non-advanced */} -
- setIncludeReject(e.target.checked)} - className="h-4 w-4 accent-[#de0f1e]" - /> - -
- - {/* Preview */} - {(() => { - const advCount = advanceMode === 'top_n' - ? topNStartup + topNConceptual - : thresholdAdvanceIds.ids.length - const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length - return ( -
-

- Advancing: {advCount} project{advCount !== 1 ? 's' : ''} - {advanceMode === 'threshold' && ( - <> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts) - )} -

- {includeReject && ( -

Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}

- )} -
- ) - })()} -
- - - - - -
-
+ {/* Advance dialog removed — use Finalization tab */} {/* Side panel Sheet */} | null = null if ((round.roundType as RoundType) === 'EVALUATION') { evaluationPassSet = new Set() const snapshot = await prisma.rankingSnapshot.findFirst({ where: { roundId }, orderBy: { createdAt: 'desc' as const }, - select: { startupRankingJson: true, conceptRankingJson: true }, + select: { startupRankingJson: true, conceptRankingJson: true, reordersJson: true }, }) if (snapshot) { const config = (round.configJson as Record) ?? {} @@ -131,21 +132,40 @@ export async function processRoundClose( const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[] const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[] + // Apply admin drag-reorder overrides (reordersJson is append-only, latest per category wins) + type ReorderEvent = { category: 'STARTUP' | 'BUSINESS_CONCEPT'; orderedProjectIds: string[] } + const reorders = (snapshot.reordersJson as ReorderEvent[] | null) ?? [] + const latestStartupReorder = [...reorders].reverse().find((r) => r.category === 'STARTUP') + const latestConceptReorder = [...reorders].reverse().find((r) => r.category === 'BUSINESS_CONCEPT') + + // Build effective order: if admin reordered, use that; otherwise use computed rank order + const effectiveStartup = latestStartupReorder + ? latestStartupReorder.orderedProjectIds + : [...startupRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId) + const effectiveConcept = latestConceptReorder + ? latestConceptReorder.orderedProjectIds + : [...conceptRanked].sort((a, b) => a.rank - b.rank).map((r) => r.projectId) + + // Build score lookup for threshold mode + const scoreMap = new Map() + for (const r of [...startupRanked, ...conceptRanked]) { + if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore) + } + if (advanceMode === 'threshold') { - for (const r of [...startupRanked, ...conceptRanked]) { - if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) { - evaluationPassSet.add(r.projectId) + for (const id of [...effectiveStartup, ...effectiveConcept]) { + const score = scoreMap.get(id) + if (score != null && score >= advanceScoreThreshold) { + evaluationPassSet.add(id) } } } else { - // 'count' mode — top N per category by rank - const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank) - const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank) - for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) { - evaluationPassSet.add(sortedStartup[i].projectId) + // 'count' mode — top N per category using effective (possibly reordered) order + for (let i = 0; i < Math.min(startupAdvanceCount, effectiveStartup.length); i++) { + evaluationPassSet.add(effectiveStartup[i]) } - for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) { - evaluationPassSet.add(sortedConcept[i].projectId) + for (let i = 0; i < Math.min(conceptAdvanceCount, effectiveConcept.length); i++) { + evaluationPassSet.add(effectiveConcept[i]) } } }