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

@@ -25,15 +25,7 @@ import {
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
type AutoTagRule = {
id: string
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
operator: 'equals' | 'contains' | 'in'
value: string
}
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
export default function EditAwardPage({
params,
@@ -70,7 +62,6 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
// Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -93,14 +84,6 @@ export default function EditAwardPage({
setVotingEndAt(formatDateForInput(award.votingEndAt))
setEvaluationRoundId(award.evaluationRoundId || '')
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
// Parse autoTagRulesJson
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
setAutoTagRules(rules.rules || [])
} else {
setAutoTagRules([])
}
}
}, [award])
@@ -119,7 +102,6 @@ export default function EditAwardPage({
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
evaluationRoundId: evaluationRoundId || undefined,
eligibilityMode,
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
})
toast.success('Award updated')
router.push(`/admin/awards/${awardId}`)
@@ -130,28 +112,6 @@ export default function EditAwardPage({
}
}
const addRule = () => {
setAutoTagRules([
...autoTagRules,
{
id: `rule-${Date.now()}`,
field: 'competitionCategory',
operator: 'equals',
value: '',
},
])
}
const removeRule = (id: string) => {
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
}
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
setAutoTagRules(
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
)
}
if (isLoading) {
return (
<div className="space-y-6">
@@ -348,135 +308,6 @@ export default function EditAwardPage({
</CardContent>
</Card>
{/* Auto-Tag Rules */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Auto-Tag Rules</CardTitle>
<CardDescription>
Deterministic eligibility rules based on project metadata
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addRule}>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{autoTagRules.length === 0 ? (
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p>
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
Rules work together with the source round setting.
</p>
</div>
) : (
<div className="space-y-3">
{autoTagRules.map((rule, index) => (
<div
key={rule.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="flex-1 grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Field</Label>
<Select
value={rule.field}
onValueChange={(v) =>
updateRule(rule.id, {
field: v as AutoTagRule['field'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="competitionCategory">
Competition Category
</SelectItem>
<SelectItem value="country">Country</SelectItem>
<SelectItem value="geographicZone">
Geographic Zone
</SelectItem>
<SelectItem value="tags">Tags</SelectItem>
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Operator</Label>
<Select
value={rule.operator}
onValueChange={(v) =>
updateRule(rule.id, {
operator: v as AutoTagRule['operator'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="in">In (comma-separated)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Value</Label>
<Input
className="h-9"
value={rule.value}
onChange={(e) =>
updateRule(rule.id, { value: e.target.value })
}
placeholder={
rule.operator === 'in'
? 'value1,value2,value3'
: 'Enter value...'
}
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeRule(rule.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{autoTagRules.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
<Info className="h-3 w-3 mt-0.5 shrink-0" />
<p>
<strong>How it works:</strong> Filter from{' '}
<Badge variant="outline" className="mx-1">
{evaluationRoundId
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
?.name || 'Selected Round'
: 'All Projects'}
</Badge>
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
</p>
</div>
)}
</CardContent>
</Card>
{/* Voting Window Card */}
<Card>
<CardHeader>

View File

@@ -89,6 +89,8 @@ import {
Vote,
ChevronDown,
AlertCircle,
Layers,
Info,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results',
})
const { data: awardRounds, refetch: refetchRounds } =
trpc.specialAward.listRounds.useQuery({ awardId }, {
enabled: activeTab === 'rounds',
})
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
const deleteAward = trpc.specialAward.delete.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(),
})
const createRound = trpc.specialAward.createRound.useMutation({
onSuccess: () => {
refetchRounds()
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: 'EVALUATION' })
toast.success('Round created')
},
onError: (err) => toast.error(err.message),
})
const deleteRound = trpc.specialAward.deleteRound.useMutation({
onSuccess: () => {
refetchRounds()
toast.success('Round deleted')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
<Users className="mr-2 h-4 w-4" />
Jurors ({award._count.jurors})
</TabsTrigger>
<TabsTrigger value="rounds">
<Layers className="mr-2 h-4 w-4" />
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
</TabsTrigger>
<TabsTrigger value="results">
<BarChart3 className="mr-2 h-4 w-4" />
Results
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
)}
</TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
</p>
</div>
)}
{!award.competitionId && (
<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">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Link this award to a competition first before creating rounds.
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" disabled={!award.competitionId}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Award Round</DialogTitle>
<DialogDescription>
Add a new round to the &quot;{award.name}&quot; award evaluation track.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="round-name">Round Name</Label>
<Input
id="round-name"
placeholder="e.g. Award Evaluation"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="round-type">Round Type</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger id="round-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVALUATION">Evaluation</SelectItem>
<SelectItem value="FILTERING">Filtering</SelectItem>
<SelectItem value="SUBMISSION">Submission</SelectItem>
<SelectItem value="MENTORING">Mentoring</SelectItem>
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button
onClick={() => createRound.mutate({
awardId,
name: roundForm.name.trim(),
roundType: roundForm.roundType as any,
})}
disabled={!roundForm.name.trim() || createRound.isPending}
>
{createRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
) : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{!awardRounds ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
) : awardRounds.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Create your first award round to build an evaluation track.
</CardContent>
</Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{awardRounds.map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
const roundTypeColors: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
return (
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
{statusLabel}
</Badge>
{index === 0 && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ roundId: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)}
</TabsContent>
{/* Results Tab */}
<TabsContent value="results" className="space-y-4">
{voteResults && voteResults.results.length > 0 ? (() => {

View File

@@ -60,6 +60,7 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
createMutation.mutate({
programId: competition.programId,
competitionId: params.competitionId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
scoringMode: formData.scoringMode,

View File

@@ -338,14 +338,14 @@ export default function CompetitionDetailPage() {
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.length})</h2>
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</div>
{competition.rounds.length === 0 ? (
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured. Add rounds to define the competition flow.
@@ -353,7 +353,7 @@ export default function CompetitionDetailPage() {
</Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{competition.rounds.map((round: any, index: number) => {
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')

View File

@@ -548,8 +548,8 @@ export default function RoundDetailPage() {
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={'/admin/rounds' as Route} className="mt-0.5 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label="Back to rounds">
<Link href={(round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds') as Route} className="mt-0.5 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
@@ -1950,8 +1950,6 @@ export default function RoundDetailPage() {
<div className="space-y-3">
{roundAwards.map((award) => {
const eligibleCount = award._count?.eligibilities || 0
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
const ruleCount = autoTagRules?.rules?.length || 0
return (
<Link
@@ -1983,10 +1981,6 @@ export default function RoundDetailPage() {
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
<div className="text-right">
<div className="font-medium text-foreground">{ruleCount}</div>
<div className="text-xs">{ruleCount === 1 ? 'rule' : 'rules'}</div>
</div>
<div className="text-right">
<div className="font-medium text-foreground">{eligibleCount}</div>
<div className="text-xs">eligible</div>