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
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:
@@ -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 "{award.name}" 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 "{round.name}". 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 ? (() => {
|
||||
|
||||
Reference in New Issue
Block a user