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: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
status: undefined,
|
||||
status: 'SUBMITTED' as const,
|
||||
tags: [],
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
{/* Advance Top N removed — use Finalization tab instead */}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -1039,164 +937,7 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Advance dialog */}
|
||||
<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>
|
||||
{/* Advance dialog removed — use Finalization tab */}
|
||||
|
||||
{/* Side panel Sheet */}
|
||||
<Sheet
|
||||
|
||||
@@ -111,14 +111,15 @@ export async function processRoundClose(
|
||||
|
||||
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
|
||||
if ((round.roundType as RoundType) === 'EVALUATION') {
|
||||
evaluationPassSet = new Set<string>()
|
||||
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<string, unknown>) ?? {}
|
||||
@@ -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<string, number>()
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user