feat: finalization tab respects ranking overrides, grouped by category
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m2s
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:
@@ -186,7 +186,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
|||||||
title: '',
|
title: '',
|
||||||
teamName: '',
|
teamName: '',
|
||||||
description: '',
|
description: '',
|
||||||
status: undefined,
|
status: 'SUBMITTED' as const,
|
||||||
tags: [],
|
tags: [],
|
||||||
competitionCategory: '',
|
competitionCategory: '',
|
||||||
oceanIssue: '',
|
oceanIssue: '',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -76,6 +76,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
|
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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
||||||
const [advancementMessage, setAdvancementMessage] = useState('')
|
const [advancementMessage, setAdvancementMessage] = useState('')
|
||||||
@@ -135,9 +136,32 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
(filterOutcome === 'none' && !p.proposedOutcome) ||
|
(filterOutcome === 'none' && !p.proposedOutcome) ||
|
||||||
p.proposedOutcome === filterOutcome
|
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
|
// Counts
|
||||||
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
|
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 })
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -362,12 +473,24 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
<SelectValue placeholder="Filter by outcome" />
|
<SelectValue placeholder="Filter by outcome" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All projects</SelectItem>
|
<SelectItem value="all">All outcomes</SelectItem>
|
||||||
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
|
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
|
||||||
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
|
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
|
||||||
<SelectItem value="none">Undecided</SelectItem>
|
<SelectItem value="none">Undecided</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bulk actions */}
|
{/* Bulk actions */}
|
||||||
@@ -422,95 +545,30 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredProjects.map((project) => (
|
{groupedProjects ? (
|
||||||
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
|
groupedProjects.map((group) => (
|
||||||
{!summary.isFinalized && (
|
<React.Fragment key={group.category}>
|
||||||
<td className="px-3 py-2.5">
|
<tr className="bg-muted/70">
|
||||||
<Checkbox
|
<td
|
||||||
checked={selectedIds.has(project.id)}
|
colSpan={colCount}
|
||||||
onCheckedChange={(checked) => {
|
className="px-3 py-2 font-semibold text-sm tracking-wide uppercase text-muted-foreground"
|
||||||
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('_', ' ')}
|
{group.label}
|
||||||
</Badge>
|
<span className="ml-2 text-xs font-normal normal-case">
|
||||||
</td>
|
({group.projects.filter((p) => p.proposedOutcome === 'PASSED').length} pass, {group.projects.filter((p) => p.proposedOutcome === 'REJECTED').length} reject, {group.projects.length} total)
|
||||||
{summary.roundType === 'EVALUATION' && (
|
</span>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{group.projects.map((project) => renderProjectRow(project))}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
filteredProjects.map((project) => renderProjectRow(project))
|
||||||
|
)}
|
||||||
{filteredProjects.length === 0 && (
|
{filteredProjects.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={summary.isFinalized ? 6 : 7}
|
colSpan={colCount}
|
||||||
className="px-3 py-8 text-center text-muted-foreground"
|
className="px-3 py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
No projects match your search/filter
|
No projects match your search/filter
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -57,7 +49,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trophy,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -251,14 +242,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
const initialized = useRef(false)
|
const initialized = useRef(false)
|
||||||
const pendingReorderCount = useRef(0)
|
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 ──────────────────────────────────────────────────────────
|
// ─── Export state ──────────────────────────────────────────────────────────
|
||||||
const [exportLoading, setExportLoading] = useState(false)
|
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) ────────────────────
|
// ─── evalConfig (advancement counts from round config) ────────────────────
|
||||||
const evalConfig = useMemo(() => {
|
const evalConfig = useMemo(() => {
|
||||||
if (!roundData?.configJson) return null
|
if (!roundData?.configJson) return null
|
||||||
@@ -518,14 +479,6 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
// Derive ranking mode from criteria text
|
// Derive ranking mode from criteria text
|
||||||
const isFormulaMode = !localCriteriaText.trim()
|
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 ────────────────────────────────────────────────────────
|
// ─── 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
|
||||||
@@ -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 ──────────────────────────────────────────────────────────
|
// ─── handleExport ──────────────────────────────────────────────────────────
|
||||||
async function handleExportScores() {
|
async function handleExportScores() {
|
||||||
setExportLoading(true)
|
setExportLoading(true)
|
||||||
@@ -758,18 +667,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* Advance Top N removed — use Finalization tab instead */}
|
||||||
size="sm"
|
|
||||||
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
|
|
||||||
onClick={() => setAdvanceDialogOpen(true)}
|
|
||||||
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
|
||||||
>
|
|
||||||
{advanceMutation.isPending ? (
|
|
||||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
|
|
||||||
) : (
|
|
||||||
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1039,164 +937,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advance dialog */}
|
{/* Advance dialog removed — use Finalization tab */}
|
||||||
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Advance Projects</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Choose how to select which projects advance to the next round.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
{/* Mode toggle */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={advanceMode === 'top_n' ? 'default' : 'outline'}
|
|
||||||
onClick={() => setAdvanceMode('top_n')}
|
|
||||||
className={advanceMode === 'top_n' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
|
|
||||||
>
|
|
||||||
Top N per category
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={advanceMode === 'threshold' ? 'default' : 'outline'}
|
|
||||||
onClick={() => setAdvanceMode('threshold')}
|
|
||||||
className={advanceMode === 'threshold' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
|
|
||||||
>
|
|
||||||
Score threshold
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{advanceMode === 'top_n' ? (
|
|
||||||
<>
|
|
||||||
{/* Top N for STARTUP */}
|
|
||||||
{localOrder.STARTUP.length > 0 && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-40 text-sm">Startups to advance</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={localOrder.STARTUP.length}
|
|
||||||
value={topNStartup}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTopNStartup(
|
|
||||||
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top N for BUSINESS_CONCEPT */}
|
|
||||||
{localOrder.BUSINESS_CONCEPT.length > 0 && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-40 text-sm">Concepts to advance</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={localOrder.BUSINESS_CONCEPT.length}
|
|
||||||
value={topNConceptual}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTopNConceptual(
|
|
||||||
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label className="w-40 text-sm">Minimum avg score</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
value={scoreThreshold}
|
|
||||||
onChange={(e) => setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))}
|
|
||||||
className="w-24"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">out of 10</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
All projects with an average global score at or above this threshold will advance, regardless of category.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Optional: also batch-reject non-advanced */}
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="includeReject"
|
|
||||||
checked={includeReject}
|
|
||||||
onChange={(e) => setIncludeReject(e.target.checked)}
|
|
||||||
className="h-4 w-4 accent-[#de0f1e]"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
|
|
||||||
Also batch-reject non-advanced projects
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{(() => {
|
|
||||||
const advCount = advanceMode === 'top_n'
|
|
||||||
? topNStartup + topNConceptual
|
|
||||||
: thresholdAdvanceIds.ids.length
|
|
||||||
const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
|
|
||||||
<p>
|
|
||||||
Advancing: {advCount} project{advCount !== 1 ? 's' : ''}
|
|
||||||
{advanceMode === 'threshold' && (
|
|
||||||
<> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts)</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{includeReject && (
|
|
||||||
<p>Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setAdvanceDialogOpen(false)}
|
|
||||||
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleAdvance}
|
|
||||||
disabled={
|
|
||||||
advanceMutation.isPending ||
|
|
||||||
batchRejectMutation.isPending ||
|
|
||||||
(advanceMode === 'top_n' ? topNStartup + topNConceptual === 0 : thresholdAdvanceIds.ids.length === 0)
|
|
||||||
}
|
|
||||||
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
|
||||||
>
|
|
||||||
{advanceMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
`Advance ${advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length} Project${(advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length) !== 1 ? 's' : ''}`
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Side panel Sheet */}
|
{/* Side panel Sheet */}
|
||||||
<Sheet
|
<Sheet
|
||||||
|
|||||||
@@ -111,14 +111,15 @@ export async function processRoundClose(
|
|||||||
|
|
||||||
let processed = 0
|
let processed = 0
|
||||||
|
|
||||||
// Pre-compute pass set for EVALUATION rounds using ranking scores + config
|
// Pre-compute pass set for EVALUATION rounds using ranking scores + config.
|
||||||
|
// Respects admin drag-reorder overrides stored in reordersJson.
|
||||||
let evaluationPassSet: Set<string> | null = null
|
let evaluationPassSet: Set<string> | null = null
|
||||||
if ((round.roundType as RoundType) === 'EVALUATION') {
|
if ((round.roundType as RoundType) === 'EVALUATION') {
|
||||||
evaluationPassSet = new Set<string>()
|
evaluationPassSet = new Set<string>()
|
||||||
const snapshot = await prisma.rankingSnapshot.findFirst({
|
const snapshot = await prisma.rankingSnapshot.findFirst({
|
||||||
where: { roundId },
|
where: { roundId },
|
||||||
orderBy: { createdAt: 'desc' as const },
|
orderBy: { createdAt: 'desc' as const },
|
||||||
select: { startupRankingJson: true, conceptRankingJson: true },
|
select: { startupRankingJson: true, conceptRankingJson: true, reordersJson: true },
|
||||||
})
|
})
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
const config = (round.configJson as Record<string, unknown>) ?? {}
|
const config = (round.configJson as Record<string, unknown>) ?? {}
|
||||||
@@ -131,21 +132,40 @@ export async function processRoundClose(
|
|||||||
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
|
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
|
||||||
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
|
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
|
||||||
|
|
||||||
if (advanceMode === 'threshold') {
|
// 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<string, number>()
|
||||||
for (const r of [...startupRanked, ...conceptRanked]) {
|
for (const r of [...startupRanked, ...conceptRanked]) {
|
||||||
if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) {
|
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
|
||||||
evaluationPassSet.add(r.projectId)
|
}
|
||||||
|
|
||||||
|
if (advanceMode === 'threshold') {
|
||||||
|
for (const id of [...effectiveStartup, ...effectiveConcept]) {
|
||||||
|
const score = scoreMap.get(id)
|
||||||
|
if (score != null && score >= advanceScoreThreshold) {
|
||||||
|
evaluationPassSet.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 'count' mode — top N per category by rank
|
// 'count' mode — top N per category using effective (possibly reordered) order
|
||||||
const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank)
|
for (let i = 0; i < Math.min(startupAdvanceCount, effectiveStartup.length); i++) {
|
||||||
const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank)
|
evaluationPassSet.add(effectiveStartup[i])
|
||||||
for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) {
|
|
||||||
evaluationPassSet.add(sortedStartup[i].projectId)
|
|
||||||
}
|
}
|
||||||
for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) {
|
for (let i = 0; i < Math.min(conceptAdvanceCount, effectiveConcept.length); i++) {
|
||||||
evaluationPassSet.add(sortedConcept[i].projectId)
|
evaluationPassSet.add(effectiveConcept[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user