Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s

- Pool page: auto-select program from edition context, URL params for roundId/competitionId deep-linking, unassigned toggle, round badges column
- Competition detail: rich round cards with project counts, dates, jury info, status badges replacing flat list
- Round detail: readiness checklist, embedded assignment dashboard, file requirements in config tab, notifyOnEntry toggle
- ProjectStatesTable: search input, project links, quick-add dialog, pool links with context params
- FilteringDashboard: expandable rows with AI reasoning inline, quick override buttons, search, clickable stats
- Backend: notifyOnEntry in round configJson triggers announcement emails on project assignment via existing email infra

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 08:23:40 +01:00
parent 7f334ed095
commit 845554fdb8
7 changed files with 1197 additions and 496 deletions

View File

@@ -1,11 +1,14 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@@ -23,41 +26,86 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const searchParams = useSearchParams()
const { currentEdition, isLoading: editionLoading } = useEdition()
// URL params for deep-linking context
const urlRoundId = searchParams.get('roundId') || ''
const urlCompetitionId = searchParams.get('competitionId') || ''
// Auto-select programId from edition
const programId = currentEdition?.id || ''
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
// Pre-select target round from URL param
useEffect(() => {
if (urlRoundId) setTargetRoundId(urlRoundId)
}, [urlRoundId])
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
programId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
unassignedOnly: showUnassignedOnly,
excludeRoundId: urlRoundId || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
{ enabled: !!programId }
)
// Load rounds from program (program.get returns rounds from all competitions)
// Load rounds from program (flattened from all competitions, now with competitionId)
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
{ id: programId },
{ enabled: !!programId }
)
const rounds = (programData?.rounds || []) as Array<{ id: string; name: string; roundType: string; sortOrder: number }>
// Get round name for context banner
const allRounds = useMemo(() => {
return (programData?.rounds || []) as Array<{
id: string
name: string
competitionId: string
status: string
_count: { projects: number; assignments: number }
}>
}, [programData])
// Filter rounds by competitionId if URL param is set
const filteredRounds = useMemo(() => {
if (urlCompetitionId) {
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
}
return allRounds
}, [allRounds, urlCompetitionId])
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
const utils = trpc.useUtils()
@@ -68,7 +116,7 @@ export default function ProjectPoolPage() {
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
@@ -83,7 +131,7 @@ export default function ProjectPoolPage() {
toast.success(`Assigned all ${result.assignedCount} projects to round`)
setSelectedProjects([])
setAssignAllDialogOpen(false)
setTargetRoundId('')
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
@@ -102,11 +150,12 @@ export default function ProjectPoolPage() {
}
const handleAssignAll = () => {
if (!targetRoundId || !selectedProgramId) return
if (!targetRoundId || !programId) return
assignAllMutation.mutate({
programId: selectedProgramId,
programId,
roundId: targetRoundId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
unassignedOnly: showUnassignedOnly,
})
}
@@ -134,6 +183,16 @@ export default function ProjectPoolPage() {
}
}
if (editionLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
@@ -143,37 +202,47 @@ export default function ProjectPoolPage() {
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex-1">
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to competition rounds
{currentEdition
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
: 'No edition selected'}
</p>
</div>
</div>
{/* Context banner when coming from a round */}
{contextRound && (
<Card className="border-blue-200 bg-blue-50/50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-600 shrink-0" />
<p className="text-sm">
Assigning to <span className="font-semibold">{contextRound.name}</span>
{' \u2014 '}
<span className="text-muted-foreground">
projects already in this round are hidden
</span>
</p>
</div>
<Link
href={`/admin/competitions/${urlCompetitionId}/rounds/${urlRoundId}` as Route}
>
<Button variant="outline" size="sm" className="shrink-0">
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back to Round
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* Filters */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: string) => {
@@ -202,14 +271,29 @@ export default function ProjectPoolPage() {
}}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Switch
id="unassigned-only"
checked={showUnassignedOnly}
onCheckedChange={(checked) => {
setShowUnassignedOnly(checked)
setCurrentPage(1)
}}
/>
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
Unassigned only
</label>
</div>
</div>
</Card>
{/* Action bar */}
{selectedProgramId && poolData && poolData.total > 0 && (
<div className="flex items-center justify-between">
{programId && poolData && poolData.total > 0 && (
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{poolData.total}</span> unassigned project{poolData.total !== 1 ? 's' : ''}
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
{showUnassignedOnly && ' (unassigned only)'}
</p>
<div className="flex items-center gap-2">
{selectedProjects.length > 0 && (
@@ -229,7 +313,7 @@ export default function ProjectPoolPage() {
)}
{/* Projects Table */}
{selectedProgramId && (
{programId ? (
<>
{isLoadingPool ? (
<Card className="p-4">
@@ -246,7 +330,7 @@ export default function ProjectPoolPage() {
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<th className="p-3 text-left w-[40px]">
<Checkbox
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
onCheckedChange={toggleSelectAll}
@@ -254,6 +338,7 @@ export default function ProjectPoolPage() {
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Rounds</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Quick Assign</th>
@@ -279,11 +364,28 @@ export default function ProjectPoolPage() {
</td>
<td className="p-3">
{project.competitionCategory && (
<Badge variant="outline">
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</td>
<td className="p-3">
{(project as any).projectRoundStates?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{(project as any).projectRoundStates.map((prs: any) => (
<Badge
key={prs.roundId}
variant="secondary"
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
>
{prs.round?.name || 'Round'}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
@@ -304,7 +406,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
@@ -351,15 +453,22 @@ export default function ProjectPoolPage() {
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
<div className="flex flex-col items-center gap-3">
<Layers className="h-8 w-8 text-muted-foreground/50" />
<p>
{showUnassignedOnly
? 'No unassigned projects found'
: urlRoundId
? 'All projects are already assigned to this round'
: 'No projects found for this program'}
</p>
</div>
</Card>
)}
</>
)}
{!selectedProgramId && (
) : (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
No edition selected. Please select an edition from the sidebar.
</Card>
)}
@@ -378,7 +487,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
@@ -405,9 +514,9 @@ export default function ProjectPoolPage() {
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign All Unassigned Projects</DialogTitle>
<DialogTitle>Assign All Projects</DialogTitle>
<DialogDescription>
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''} unassigned projects to a round in one operation.
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -416,7 +525,7 @@ export default function ProjectPoolPage() {
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>