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,6 +1,6 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -8,6 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@@ -52,6 +53,8 @@ import {
Layers,
Trash2,
Plus,
Search,
ExternalLink,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
@@ -76,13 +79,17 @@ type ProjectStatesTableProps = {
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('')
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
const utils = trpc.useUtils()
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
)
@@ -145,9 +152,21 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
})
}
const filtered = projectStates?.filter((ps: any) =>
stateFilter === 'ALL' ? true : ps.state === stateFilter
) ?? []
// Apply state filter first, then search filter
const filtered = useMemo(() => {
let result = projectStates ?? []
if (stateFilter !== 'ALL') {
result = result.filter((ps: any) => ps.state === stateFilter)
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter((ps: any) =>
(ps.project?.title || '').toLowerCase().includes(q) ||
(ps.project?.teamName || '').toLowerCase().includes(q)
)
}
return result
}, [projectStates, stateFilter, searchQuery])
const toggleSelectAll = useCallback(() => {
const ids = filtered.map((ps: any) => ps.projectId)
@@ -196,7 +215,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool to this round to get started.
</p>
<Link href={'/admin/projects/pool' as Route}>
<Link href={poolLink}>
<Button size="sm" className="mt-4">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
@@ -210,46 +229,70 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
return (
<div className="space-y-4">
{/* Top bar: filters + add button */}
{/* Top bar: search + filters + add buttons */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap gap-2">
<button
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === 'ALL'
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
All ({projectStates.length})
</button>
{PROJECT_STATES.map((state) => {
const count = counts[state] || 0
if (count === 0) return null
const cfg = stateConfig[state]
return (
<button
key={state}
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === state
? cfg.color + ' border-current'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{cfg.label} ({count})
</button>
)
})}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === 'ALL'
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
All ({projectStates.length})
</button>
{PROJECT_STATES.map((state) => {
const count = counts[state] || 0
if (count === 0) return null
const cfg = stateConfig[state]
return (
<button
key={state}
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === state
? cfg.color + ' border-current'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{cfg.label} ({count})
</button>
)
})}
</div>
</div>
<Link href={'/admin/projects/pool' as Route}>
<Button size="sm" variant="outline">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setQuickAddOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
Quick Add
</Button>
</Link>
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Add from Pool
</Button>
</Link>
</div>
</div>
{/* Search results count */}
{searchQuery.trim() && (
<p className="text-xs text-muted-foreground">
Showing {filtered.length} of {projectStates.length} projects matching &quot;{searchQuery}&quot;
</p>
)}
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
@@ -316,7 +359,12 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
/>
</div>
<div className="min-w-0">
<p className="font-medium truncate">{ps.project?.title || 'Unknown'}</p>
<Link
href={`/admin/projects/${ps.projectId}` as Route}
className="font-medium truncate block hover:underline text-foreground"
>
{ps.project?.title || 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
</div>
<div>
@@ -341,6 +389,13 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${ps.projectId}` as Route}>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
View Project
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
const sCfg = stateConfig[state]
return (
@@ -368,8 +423,25 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
)
})}
{filtered.length === 0 && searchQuery.trim() && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No projects match &quot;{searchQuery}&quot;
</div>
)}
</div>
{/* Quick Add Dialog */}
<QuickAddDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent>
@@ -466,3 +538,133 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
</div>
)
}
/**
* Quick Add Dialog — inline search + assign projects to this round without leaving the page.
*/
function QuickAddDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [search, setSearch] = useState('')
const [addingIds, setAddingIds] = useState<Set<string>>(new Set())
// Get the competition to find programId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: search.trim() || undefined,
perPage: 10,
},
{ enabled: open && !!programId },
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`Added to round`)
onAssigned()
// Remove from addingIds
setAddingIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const handleQuickAssign = (projectId: string) => {
setAddingIds((prev) => new Set(prev).add(projectId))
assignMutation.mutate({ projectIds: [projectId], roundId })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Quick Add Projects</DialogTitle>
<DialogDescription>
Search and assign projects to this round without leaving the page.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
autoFocus
/>
</div>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}
</p>
)}
{poolResults?.projects.map((project: any) => (
<div
key={project.id}
className="flex items-center justify-between gap-3 p-2.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.competitionCategory && (
<> &middot; {project.competitionCategory}</>
)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={assignMutation.isPending && addingIds.has(project.id)}
onClick={() => handleQuickAssign(project.id)}
>
{assignMutation.isPending && addingIds.has(project.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</>
)}
</Button>
</div>
))}
</div>
{poolResults && poolResults.total > 10 && (
<p className="text-xs text-muted-foreground text-center">
Showing 10 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
</DialogContent>
</Dialog>
)
}