Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s

- Add Rounds tab to award detail page with create/list/delete functionality
- Add "Entry point" badge on first award round (confirmShortlist routes here)
- Fix round detail back-link to navigate to parent award when specialAwardId set
- Filter award rounds out of competition round list
- Add specialAwardId to competition getById round select
- Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode)
- Remove auto-tag rules from award config, edit page, router, and AI service
- Fix competitionId not passed when creating awards from competition context
- Add AUTO_FILTER quality threshold to AI filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 19:53:20 +01:00
parent 4fa3ca0bb6
commit 1fe6667400
13 changed files with 389 additions and 340 deletions

View File

@@ -34,6 +34,7 @@ import {
Play,
Star,
Trophy,
AlertTriangle,
} from 'lucide-react'
type AwardShortlistProps = {
@@ -92,6 +93,12 @@ export function AwardShortlist({
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
{ awardId },
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
)
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
@@ -210,11 +217,23 @@ export function AwardShortlist({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
<AlertDialogDescription>
{eligibilityMode === 'SEPARATE_POOL'
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
}
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{eligibilityMode === 'SEPARATE_POOL'
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
}
</p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -107,9 +107,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
// AI criteria state
const [criteriaText, setCriteriaText] = useState('')
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('FLAG')
const [aiAction, setAiAction] = useState<'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'>('FLAG')
const [batchSize, setBatchSize] = useState(20)
const [parallelBatches, setParallelBatches] = useState(3)
const [autoFilterThreshold, setAutoFilterThreshold] = useState(4)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [criteriaDirty, setCriteriaDirty] = useState(false)
const criteriaLoaded = useRef(false)
@@ -168,9 +169,10 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
if (aiRule && !criteriaLoaded.current) {
const config = (aiRule.configJson || {}) as Record<string, unknown>
setCriteriaText((config.criteriaText as string) || '')
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG')
setAiAction((config.action as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER') || 'FLAG')
setBatchSize((config.batchSize as number) || 20)
setParallelBatches((config.parallelBatches as number) || 3)
setAutoFilterThreshold((config.autoFilterThreshold as number) || 4)
criteriaLoaded.current = true
}
}, [aiRule])
@@ -269,6 +271,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
action: aiAction,
batchSize,
parallelBatches,
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
}
try {
@@ -296,6 +299,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
action: aiAction,
batchSize,
parallelBatches,
...(aiAction === 'AUTO_FILTER' && { autoFilterThreshold }),
}
if (aiRule) {
@@ -519,42 +523,87 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
{advancedOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="grid grid-cols-3 gap-3 rounded-lg border p-3 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG'); setCriteriaDirty(true) }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
<SelectItem value="PASS">Auto-pass matches</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={batchSize}
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={parallelBatches}
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
/>
<div className="space-y-3 rounded-lg border p-3 bg-muted/20">
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Default Action</Label>
<Select value={aiAction} onValueChange={(v) => { setAiAction(v as 'PASS' | 'REJECT' | 'FLAG' | 'AUTO_FILTER'); setCriteriaDirty(true) }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AUTO_FILTER">Smart auto-filter</SelectItem>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject failures</SelectItem>
<SelectItem value="PASS">Auto-pass matches</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={batchSize}
onChange={(e) => { setBatchSize(parseInt(e.target.value) || 20); setCriteriaDirty(true) }}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={parallelBatches}
onChange={(e) => { setParallelBatches(parseInt(e.target.value) || 3); setCriteriaDirty(true) }}
/>
</div>
</div>
{aiAction === 'AUTO_FILTER' && (
<div className="rounded-md border border-purple-200 bg-purple-50/50 p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium">Quality threshold</Label>
<p className="text-xs text-muted-foreground">
Projects scoring at or below this are auto-rejected as spam/junk
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={9}
className="h-8 w-16 text-xs text-center"
value={autoFilterThreshold}
onChange={(e) => {
const v = Math.min(9, Math.max(1, parseInt(e.target.value) || 4))
setAutoFilterThreshold(v)
setCriteriaDirty(true)
}}
/>
<span className="text-xs text-muted-foreground">/10</span>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5 text-xs">
<div className="rounded bg-red-100 border border-red-200 px-2 py-1.5 text-center">
<span className="font-medium text-red-800">Auto-reject</span>
<p className="text-red-600 mt-0.5">Score 1-{autoFilterThreshold} or spam</p>
</div>
<div className="rounded bg-amber-100 border border-amber-200 px-2 py-1.5 text-center">
<span className="font-medium text-amber-800">Flag for review</span>
<p className="text-amber-600 mt-0.5">Score {autoFilterThreshold + 1}+ but fails criteria</p>
</div>
<div className="rounded bg-green-100 border border-green-200 px-2 py-1.5 text-center">
<span className="font-medium text-green-800">Auto-pass</span>
<p className="text-green-600 mt-0.5">Meets criteria</p>
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>