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
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:
@@ -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 "{searchQuery}"
|
||||
</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 "{searchQuery}"
|
||||
</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 && (
|
||||
<> · {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} — refine your search for more specific results
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user