feat: finalization tab respects ranking overrides, grouped by category
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m2s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 22:10:04 +01:00
parent 43801340f8
commit 050836d522
4 changed files with 182 additions and 363 deletions

View File

@@ -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<Set<string>>(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]) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary?.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge variant="secondary" className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary?.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary?.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
)
if (isLoading) {
return (
<div className="space-y-4">
@@ -362,12 +473,24 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
<SelectValue placeholder="Filter by outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="all">All outcomes</SelectItem>
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
<SelectItem value="none">Undecided</SelectItem>
</SelectContent>
</Select>
{hasMultipleCategories && (
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as typeof filterCategory)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
<SelectItem value="STARTUP">Startups</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concepts</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Bulk actions */}
@@ -422,95 +545,30 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge
variant="secondary"
className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}
>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
{groupedProjects ? (
groupedProjects.map((group) => (
<React.Fragment key={group.category}>
<tr className="bg-muted/70">
<td
colSpan={colCount}
className="px-3 py-2 font-semibold text-sm tracking-wide uppercase text-muted-foreground"
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
))}
{group.label}
<span className="ml-2 text-xs font-normal normal-case">
({group.projects.filter((p) => p.proposedOutcome === 'PASSED').length} pass, {group.projects.filter((p) => p.proposedOutcome === 'REJECTED').length} reject, {group.projects.length} total)
</span>
</td>
</tr>
{group.projects.map((project) => renderProjectRow(project))}
</React.Fragment>
))
) : (
filteredProjects.map((project) => renderProjectRow(project))
)}
{filteredProjects.length === 0 && (
<tr>
<td
colSpan={summary.isFinalized ? 6 : 7}
colSpan={colCount}
className="px-3 py-8 text-center text-muted-foreground"
>
No projects match your search/filter