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

@@ -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