Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00
'use client'
import { useState , useMemo , useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuSeparator ,
DropdownMenuTrigger ,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog ,
AlertDialogAction ,
AlertDialogCancel ,
AlertDialogContent ,
AlertDialogDescription ,
AlertDialogFooter ,
AlertDialogHeader ,
AlertDialogTitle ,
} from '@/components/ui/alert-dialog'
import {
Dialog ,
DialogContent ,
DialogDescription ,
DialogFooter ,
DialogHeader ,
DialogTitle ,
} from '@/components/ui/dialog'
import { Tabs , TabsContent , TabsList , TabsTrigger } from '@/components/ui/tabs'
import {
Command ,
CommandEmpty ,
CommandGroup ,
CommandInput ,
CommandItem ,
CommandList ,
} from '@/components/ui/command'
import {
Popover ,
PopoverContent ,
PopoverTrigger ,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Loader2 ,
Plus ,
Trash2 ,
RotateCcw ,
Check ,
ChevronsUpDown ,
Search ,
MoreHorizontal ,
UserPlus ,
} from 'lucide-react'
export type IndividualAssignmentsTableProps = {
roundId : string
projectStates : any [ ] | undefined
}
export function IndividualAssignmentsTable ( {
roundId ,
projectStates ,
} : IndividualAssignmentsTableProps ) {
const [ addDialogOpen , setAddDialogOpen ] = useState ( false )
const [ confirmAction , setConfirmAction ] = useState < { type : 'reset' | 'delete' ; assignment : any } | null > ( null )
const [ assignMode , setAssignMode ] = useState < 'byJuror' | 'byProject' > ( 'byJuror' )
// ── By Juror mode state ──
const [ selectedJurorId , setSelectedJurorId ] = useState ( '' )
const [ selectedProjectIds , setSelectedProjectIds ] = useState < Set < string > > ( new Set ( ) )
const [ jurorPopoverOpen , setJurorPopoverOpen ] = useState ( false )
const [ projectSearch , setProjectSearch ] = useState ( '' )
// ── By Project mode state ──
const [ selectedProjectId , setSelectedProjectId ] = useState ( '' )
const [ selectedJurorIds , setSelectedJurorIds ] = useState < Set < string > > ( new Set ( ) )
const [ projectPopoverOpen , setProjectPopoverOpen ] = useState ( false )
const [ jurorSearch , setJurorSearch ] = useState ( '' )
const utils = trpc . useUtils ( )
const { data : assignments , isLoading } = trpc . assignment . listByStage . useQuery (
{ roundId } ,
{ refetchInterval : 15_000 } ,
)
const { data : juryMembers } = trpc . user . getJuryMembers . useQuery (
{ roundId } ,
{ enabled : addDialogOpen } ,
)
const deleteMutation = trpc . assignment . delete . useMutation ( {
onSuccess : ( ) = > {
utils . assignment . listByStage . invalidate ( { roundId } )
utils . roundEngine . getProjectStates . invalidate ( { roundId } )
toast . success ( 'Assignment removed' )
} ,
onError : ( err ) = > toast . error ( err . message ) ,
} )
const resetEvalMutation = trpc . evaluation . resetEvaluation . useMutation ( {
onSuccess : ( ) = > {
utils . assignment . listByStage . invalidate ( { roundId } )
toast . success ( 'Evaluation reset — juror can now start over' )
} ,
onError : ( err ) = > toast . error ( err . message ) ,
} )
const reassignCOIMutation = trpc . assignment . reassignCOI . useMutation ( {
onSuccess : ( data ) = > {
utils . assignment . listByStage . invalidate ( { roundId } )
utils . roundEngine . getProjectStates . invalidate ( { roundId } )
utils . analytics . getJurorWorkload . invalidate ( { roundId } )
utils . evaluation . listCOIByStage . invalidate ( { roundId } )
toast . success ( ` Reassigned to ${ data . newJurorName } ` )
} ,
onError : ( err ) = > toast . error ( err . message ) ,
} )
const createMutation = trpc . assignment . create . useMutation ( {
onSuccess : ( ) = > {
utils . assignment . listByStage . invalidate ( { roundId } )
utils . roundEngine . getProjectStates . invalidate ( { roundId } )
utils . user . getJuryMembers . invalidate ( { roundId } )
toast . success ( 'Assignment created' )
resetDialog ( )
} ,
onError : ( err ) = > toast . error ( err . message ) ,
} )
const bulkCreateMutation = trpc . assignment . bulkCreate . useMutation ( {
onSuccess : ( result ) = > {
utils . assignment . listByStage . invalidate ( { roundId } )
utils . roundEngine . getProjectStates . invalidate ( { roundId } )
utils . user . getJuryMembers . invalidate ( { roundId } )
toast . success ( ` ${ result . created } assignment(s) created ` )
resetDialog ( )
} ,
onError : ( err ) = > toast . error ( err . message ) ,
} )
const resetDialog = useCallback ( ( ) = > {
setAddDialogOpen ( false )
setAssignMode ( 'byJuror' )
setSelectedJurorId ( '' )
setSelectedProjectIds ( new Set ( ) )
setProjectSearch ( '' )
setSelectedProjectId ( '' )
setSelectedJurorIds ( new Set ( ) )
setJurorSearch ( '' )
} , [ ] )
const selectedJuror = useMemo (
( ) = > juryMembers ? . find ( ( j : any ) = > j . id === selectedJurorId ) ,
[ juryMembers , selectedJurorId ] ,
)
// Filter projects by search term
const filteredProjects = useMemo ( ( ) = > {
const items = projectStates ? ? [ ]
if ( ! projectSearch ) return items
const q = projectSearch . toLowerCase ( )
return items . filter ( ( ps : any ) = >
ps . project ? . title ? . toLowerCase ( ) . includes ( q ) ||
ps . project ? . teamName ? . toLowerCase ( ) . includes ( q ) ||
ps . project ? . competitionCategory ? . toLowerCase ( ) . includes ( q )
)
} , [ projectStates , projectSearch ] )
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo ( ( ) = > {
if ( ! selectedJurorId || ! assignments ) return new Set < string > ( )
return new Set (
assignments
. filter ( ( a : any ) = > a . userId === selectedJurorId )
. map ( ( a : any ) = > a . projectId )
)
} , [ selectedJurorId , assignments ] )
const toggleProject = useCallback ( ( projectId : string ) = > {
setSelectedProjectIds ( prev = > {
const next = new Set ( prev )
if ( next . has ( projectId ) ) {
next . delete ( projectId )
} else {
next . add ( projectId )
}
return next
} )
} , [ ] )
const selectAllUnassigned = useCallback ( ( ) = > {
const unassigned = filteredProjects
. filter ( ( ps : any ) = > ! jurorExistingProjectIds . has ( ps . project ? . id ) )
. map ( ( ps : any ) = > ps . project ? . id )
. filter ( Boolean )
setSelectedProjectIds ( new Set ( unassigned ) )
} , [ filteredProjects , jurorExistingProjectIds ] )
const handleCreate = useCallback ( ( ) = > {
if ( ! selectedJurorId || selectedProjectIds . size === 0 ) return
const projectIds = Array . from ( selectedProjectIds )
if ( projectIds . length === 1 ) {
createMutation . mutate ( {
userId : selectedJurorId ,
projectId : projectIds [ 0 ] ,
roundId ,
} )
} else {
bulkCreateMutation . mutate ( {
roundId ,
assignments : projectIds.map ( projectId = > ( {
userId : selectedJurorId ,
projectId ,
} ) ) ,
} )
}
} , [ selectedJurorId , selectedProjectIds , roundId , createMutation , bulkCreateMutation ] )
const isMutating = createMutation . isPending || bulkCreateMutation . isPending
// ── By Project mode helpers ──
// Existing assignments for the selected project (to grey out already-assigned jurors)
const projectExistingJurorIds = useMemo ( ( ) = > {
if ( ! selectedProjectId || ! assignments ) return new Set < string > ( )
return new Set (
assignments
. filter ( ( a : any ) = > a . projectId === selectedProjectId )
. map ( ( a : any ) = > a . userId )
)
} , [ selectedProjectId , assignments ] )
// Count assignments per juror in this round (for display)
const jurorAssignmentCounts = useMemo ( ( ) = > {
if ( ! assignments ) return new Map < string , number > ( )
const counts = new Map < string , number > ( )
for ( const a of assignments ) {
counts . set ( a . userId , ( counts . get ( a . userId ) || 0 ) + 1 )
}
return counts
} , [ assignments ] )
// Filter jurors by search term
const filteredJurors = useMemo ( ( ) = > {
const items = juryMembers ? ? [ ]
if ( ! jurorSearch ) return items
const q = jurorSearch . toLowerCase ( )
return items . filter ( ( j : any ) = >
j . name ? . toLowerCase ( ) . includes ( q ) ||
j . email ? . toLowerCase ( ) . includes ( q )
)
} , [ juryMembers , jurorSearch ] )
const toggleJuror = useCallback ( ( jurorId : string ) = > {
setSelectedJurorIds ( prev = > {
const next = new Set ( prev )
if ( next . has ( jurorId ) ) next . delete ( jurorId )
else next . add ( jurorId )
return next
} )
} , [ ] )
const handleCreateByProject = useCallback ( ( ) = > {
if ( ! selectedProjectId || selectedJurorIds . size === 0 ) return
const jurorIds = Array . from ( selectedJurorIds )
if ( jurorIds . length === 1 ) {
createMutation . mutate ( {
userId : jurorIds [ 0 ] ,
projectId : selectedProjectId ,
roundId ,
} )
} else {
bulkCreateMutation . mutate ( {
roundId ,
assignments : jurorIds.map ( userId = > ( {
userId ,
projectId : selectedProjectId ,
} ) ) ,
} )
}
} , [ selectedProjectId , selectedJurorIds , roundId , createMutation , bulkCreateMutation ] )
return (
< div className = "space-y-4" >
< div className = "flex items-center justify-between" >
< div >
< p className = "text-sm font-medium" > { assignments ? . length ? ? 0 } individual assignments < / p >
< / div >
< Button size = "sm" variant = "outline" onClick = { ( ) = > setAddDialogOpen ( true ) } >
< Plus className = "h-4 w-4 mr-1.5" / >
Add
< / Button >
< / div >
< div >
{ isLoading ? (
< div className = "space-y-2" >
{ [ 1 , 2 , 3 , 4 , 5 ] . map ( ( i ) = > < Skeleton key = { i } className = "h-12 w-full" / > ) }
< / div >
) : ! assignments || assignments . length === 0 ? (
< p className = "text-sm text-muted-foreground text-center py-6" >
No assignments yet . Generate assignments or add one manually .
< / p >
) : (
< div className = "space-y-1 max-h-[500px] overflow-y-auto" >
2026-02-25 15:19:30 +01:00
< div className = "grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b" >
Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00
< span > Juror < / span >
< span > Project < / span >
< span > Status < / span >
2026-02-25 15:19:30 +01:00
< span > Advance < / span >
Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00
< span > Actions < / span >
< / div >
{ assignments . map ( ( a : any , idx : number ) = > (
< div
key = { a . id }
className = { cn (
2026-02-25 15:19:30 +01:00
'grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors' ,
Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20' ,
) }
>
< span className = "truncate" > { a . user ? . name || a . user ? . email || 'Unknown' } < / span >
< span className = "truncate text-muted-foreground" > { a . project ? . title || 'Unknown' } < / span >
< div className = "flex items-center gap-1" >
{ a . conflictOfInterest ? . hasConflict ? (
< Badge variant = "outline" className = "text-[10px] justify-center bg-red-50 text-red-700 border-red-200" >
COI
< / Badge >
) : (
< Badge
variant = "outline"
className = { cn (
'text-[10px] justify-center' ,
a . evaluation ? . status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a . evaluation ? . status === 'DRAFT'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200' ,
) }
>
{ a . evaluation ? . status || 'PENDING' }
< / Badge >
) }
< / div >
2026-02-25 15:19:30 +01:00
< div className = "flex items-center justify-center" >
{ ( ( ) = > {
const ev = a . evaluation
if ( ! ev || ev . status !== 'SUBMITTED' ) return < span className = "text-muted-foreground text-xs" > — < / span >
const criteria = ( ev . form ? . criteriaJson ? ? [ ] ) as Array < { id : string ; type ? : string } >
const scores = ( ev . criterionScoresJson ? ? { } ) as Record < string , unknown >
const advCrit = criteria . find ( ( c : any ) = > c . type === 'advance' )
if ( ! advCrit ) return < span className = "text-muted-foreground text-xs" > — < / span >
const val = scores [ advCrit . id ]
if ( val === true ) return < Badge variant = "outline" className = "text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200" > YES < / Badge >
if ( val === false ) return < Badge variant = "outline" className = "text-[10px] bg-red-50 text-red-700 border-red-200" > NO < / Badge >
return < span className = "text-muted-foreground text-xs" > — < / span >
} ) ( ) }
< / div >
Admin dashboard & round management UX overhaul
- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:14:00 +01:00
< DropdownMenu >
< DropdownMenuTrigger asChild >
< Button variant = "ghost" size = "icon" className = "h-7 w-7" >
< MoreHorizontal className = "h-3.5 w-3.5 text-muted-foreground" / >
< / Button >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "end" >
{ a . conflictOfInterest ? . hasConflict && (
< >
< DropdownMenuItem
onClick = { ( ) = > reassignCOIMutation . mutate ( { assignmentId : a.id } ) }
disabled = { reassignCOIMutation . isPending }
>
< UserPlus className = "h-3.5 w-3.5 mr-2 text-blue-600" / >
Reassign ( COI )
< / DropdownMenuItem >
< DropdownMenuSeparator / >
< / >
) }
{ a . evaluation && (
< >
< DropdownMenuItem
onClick = { ( ) = > setConfirmAction ( { type : 'reset' , assignment : a } ) }
disabled = { resetEvalMutation . isPending }
>
< RotateCcw className = "h-3.5 w-3.5 mr-2" / >
Reset Evaluation
< / DropdownMenuItem >
< DropdownMenuSeparator / >
< / >
) }
< DropdownMenuItem
className = "text-destructive focus:text-destructive"
onClick = { ( ) = > setConfirmAction ( { type : 'delete' , assignment : a } ) }
disabled = { deleteMutation . isPending }
>
< Trash2 className = "h-3.5 w-3.5 mr-2" / >
Delete Assignment
< / DropdownMenuItem >
< / DropdownMenuContent >
< / DropdownMenu >
< / div >
) ) }
< / div >
) }
< / div >
{ /* Add Assignment Dialog */ }
< Dialog open = { addDialogOpen } onOpenChange = { ( open ) = > {
if ( ! open ) resetDialog ( )
else setAddDialogOpen ( true )
} } >
< DialogContent className = "max-w-3xl" >
< DialogHeader >
< DialogTitle > Add Assignment < / DialogTitle >
< DialogDescription >
{ assignMode === 'byJuror'
? 'Select a juror, then choose projects to assign'
: 'Select a project, then choose jurors to assign'
}
< / DialogDescription >
< / DialogHeader >
{ /* Mode Toggle */ }
< Tabs value = { assignMode } onValueChange = { ( v ) = > {
setAssignMode ( v as 'byJuror' | 'byProject' )
// Reset selections when switching
setSelectedJurorId ( '' )
setSelectedProjectIds ( new Set ( ) )
setProjectSearch ( '' )
setSelectedProjectId ( '' )
setSelectedJurorIds ( new Set ( ) )
setJurorSearch ( '' )
} } >
< TabsList className = "grid w-full grid-cols-2" >
< TabsTrigger value = "byJuror" > By Juror < / TabsTrigger >
< TabsTrigger value = "byProject" > By Project < / TabsTrigger >
< / TabsList >
{ /* ── By Juror Tab ── */ }
< TabsContent value = "byJuror" className = "space-y-4 mt-4" >
{ /* Juror Selector */ }
< div className = "space-y-2" >
< Label className = "text-sm font-medium" > Juror < / Label >
< Popover open = { jurorPopoverOpen } onOpenChange = { setJurorPopoverOpen } >
< PopoverTrigger asChild >
< Button
variant = "outline"
role = "combobox"
aria - expanded = { jurorPopoverOpen }
className = "w-full justify-between font-normal"
>
{ selectedJuror
? (
< span className = "flex items-center gap-2 truncate" >
< span className = "truncate" > { selectedJuror . name || selectedJuror . email } < / span >
< Badge variant = "secondary" className = "text-[10px] shrink-0" >
{ selectedJuror . currentAssignments } / { selectedJuror . maxAssignments ? ? '\u221E' }
< / Badge >
< / span >
)
: < span className = "text-muted-foreground" > Select a jury member . . . < / span >
}
< ChevronsUpDown className = "ml-2 h-4 w-4 shrink-0 opacity-50" / >
< / Button >
< / PopoverTrigger >
< PopoverContent className = "w-[var(--radix-popover-trigger-width)] p-0" align = "start" >
< Command >
< CommandInput placeholder = "Search by name or email..." / >
< CommandList >
< CommandEmpty > No jury members found . < / CommandEmpty >
< CommandGroup >
{ juryMembers ? . map ( ( juror : any ) = > {
const atCapacity = juror . maxAssignments !== null && juror . availableSlots === 0
return (
< CommandItem
key = { juror . id }
value = { ` ${ juror . name ? ? '' } ${ juror . email } ` }
disabled = { atCapacity }
onSelect = { ( ) = > {
setSelectedJurorId ( juror . id === selectedJurorId ? '' : juror . id )
setSelectedProjectIds ( new Set ( ) )
setJurorPopoverOpen ( false )
} }
>
< Check
className = { cn (
'mr-2 h-4 w-4' ,
selectedJurorId === juror . id ? 'opacity-100' : 'opacity-0' ,
) }
/ >
< div className = "flex flex-1 items-center justify-between min-w-0" >
< div className = "min-w-0" >
< p className = "text-sm font-medium truncate" >
{ juror . name || 'Unnamed' }
< / p >
< p className = "text-xs text-muted-foreground truncate" >
{ juror . email }
< / p >
< / div >
< Badge
variant = { atCapacity ? 'destructive' : 'secondary' }
className = "text-[10px] ml-2 shrink-0"
>
{ juror . currentAssignments } / { juror . maxAssignments ? ? '\u221E' }
{ atCapacity ? ' full' : '' }
< / Badge >
< / div >
< / CommandItem >
)
} ) }
< / CommandGroup >
< / CommandList >
< / Command >
< / PopoverContent >
< / Popover >
< / div >
{ /* Project Multi-Select */ }
< div className = "space-y-2" >
< div className = "flex items-center justify-between" >
< Label className = "text-sm font-medium" >
Projects
{ selectedProjectIds . size > 0 && (
< span className = "ml-1.5 text-muted-foreground font-normal" >
( { selectedProjectIds . size } selected )
< / span >
) }
< / Label >
{ selectedJurorId && (
< div className = "flex items-center gap-2" >
< Button
type = "button"
variant = "ghost"
size = "sm"
className = "h-7 text-xs"
onClick = { selectAllUnassigned }
>
Select all
< / Button >
{ selectedProjectIds . size > 0 && (
< Button
type = "button"
variant = "ghost"
size = "sm"
className = "h-7 text-xs"
onClick = { ( ) = > setSelectedProjectIds ( new Set ( ) ) }
>
Clear
< / Button >
) }
< / div >
) }
< / div >
{ /* Search input */ }
< div className = "relative" >
< Search className = "absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" / >
< Input
placeholder = "Filter projects..."
value = { projectSearch }
onChange = { ( e ) = > setProjectSearch ( e . target . value ) }
className = "pl-9 h-9"
/ >
< / div >
{ /* Project checklist */ }
< ScrollArea className = "h-[320px] rounded-md border" >
< div className = "p-2 space-y-0.5" >
{ ! selectedJurorId ? (
< p className = "text-sm text-muted-foreground text-center py-8" >
Select a juror first
< / p >
) : filteredProjects . length === 0 ? (
< p className = "text-sm text-muted-foreground text-center py-8" >
No projects found
< / p >
) : (
filteredProjects . map ( ( ps : any ) = > {
const project = ps . project
if ( ! project ) return null
const alreadyAssigned = jurorExistingProjectIds . has ( project . id )
const isSelected = selectedProjectIds . has ( project . id )
return (
< label
key = { project . id }
className = { cn (
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors' ,
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50' ,
) }
>
< Checkbox
checked = { isSelected }
disabled = { alreadyAssigned }
onCheckedChange = { ( ) = > toggleProject ( project . id ) }
/ >
< div className = "flex flex-1 items-center justify-between min-w-0" >
< span className = "truncate" > { project . title } < / span >
< div className = "flex items-center gap-1.5 shrink-0 ml-2" >
{ project . competitionCategory && (
< Badge variant = "outline" className = "text-[10px]" >
{ project . competitionCategory === 'STARTUP'
? 'Startup'
: project . competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project . competitionCategory }
< / Badge >
) }
{ alreadyAssigned && (
< Badge variant = "secondary" className = "text-[10px]" >
Assigned
< / Badge >
) }
< / div >
< / div >
< / label >
)
} )
) }
< / div >
< / ScrollArea >
< / div >
< DialogFooter >
< Button variant = "outline" onClick = { resetDialog } >
Cancel
< / Button >
< Button
onClick = { handleCreate }
disabled = { ! selectedJurorId || selectedProjectIds . size === 0 || isMutating }
>
{ isMutating && < Loader2 className = "h-4 w-4 mr-1.5 animate-spin" / > }
{ selectedProjectIds . size <= 1
? 'Create Assignment'
: ` Create ${ selectedProjectIds . size } Assignments `
}
< / Button >
< / DialogFooter >
< / TabsContent >
{ /* ── By Project Tab ── */ }
< TabsContent value = "byProject" className = "space-y-4 mt-4" >
{ /* Project Selector */ }
< div className = "space-y-2" >
< Label className = "text-sm font-medium" > Project < / Label >
< Popover open = { projectPopoverOpen } onOpenChange = { setProjectPopoverOpen } >
< PopoverTrigger asChild >
< Button
variant = "outline"
role = "combobox"
aria - expanded = { projectPopoverOpen }
className = "w-full justify-between font-normal"
>
{ selectedProjectId
? (
< span className = "truncate" >
{ ( projectStates ? ? [ ] ) . find ( ( ps : any ) = > ps . project ? . id === selectedProjectId ) ? . project ? . title || 'Unknown' }
< / span >
)
: < span className = "text-muted-foreground" > Select a project . . . < / span >
}
< ChevronsUpDown className = "ml-2 h-4 w-4 shrink-0 opacity-50" / >
< / Button >
< / PopoverTrigger >
< PopoverContent className = "w-[var(--radix-popover-trigger-width)] p-0" align = "start" >
< Command >
< CommandInput placeholder = "Search by project title..." / >
< CommandList >
< CommandEmpty > No projects found . < / CommandEmpty >
< CommandGroup >
{ ( projectStates ? ? [ ] ) . map ( ( ps : any ) = > {
const project = ps . project
if ( ! project ) return null
return (
< CommandItem
key = { project . id }
value = { ` ${ project . title ? ? '' } ${ project . teamName ? ? '' } ` }
onSelect = { ( ) = > {
setSelectedProjectId ( project . id === selectedProjectId ? '' : project . id )
setSelectedJurorIds ( new Set ( ) )
setProjectPopoverOpen ( false )
} }
>
< Check
className = { cn (
'mr-2 h-4 w-4' ,
selectedProjectId === project . id ? 'opacity-100' : 'opacity-0' ,
) }
/ >
< div className = "flex flex-1 items-center justify-between min-w-0" >
< div className = "min-w-0" >
< p className = "text-sm font-medium truncate" > { project . title } < / p >
< p className = "text-xs text-muted-foreground truncate" > { project . teamName } < / p >
< / div >
{ project . competitionCategory && (
< Badge variant = "outline" className = "text-[10px] ml-2 shrink-0" >
{ project . competitionCategory === 'STARTUP' ? 'Startup' : 'Concept' }
< / Badge >
) }
< / div >
< / CommandItem >
)
} ) }
< / CommandGroup >
< / CommandList >
< / Command >
< / PopoverContent >
< / Popover >
< / div >
{ /* Juror Multi-Select */ }
< div className = "space-y-2" >
< div className = "flex items-center justify-between" >
< Label className = "text-sm font-medium" >
Jurors
{ selectedJurorIds . size > 0 && (
< span className = "ml-1.5 text-muted-foreground font-normal" >
( { selectedJurorIds . size } selected )
< / span >
) }
< / Label >
{ selectedProjectId && selectedJurorIds . size > 0 && (
< Button
type = "button"
variant = "ghost"
size = "sm"
className = "h-7 text-xs"
onClick = { ( ) = > setSelectedJurorIds ( new Set ( ) ) }
>
Clear
< / Button >
) }
< / div >
{ /* Search input */ }
< div className = "relative" >
< Search className = "absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" / >
< Input
placeholder = "Filter jurors..."
value = { jurorSearch }
onChange = { ( e ) = > setJurorSearch ( e . target . value ) }
className = "pl-9 h-9"
/ >
< / div >
{ /* Juror checklist */ }
< ScrollArea className = "h-[320px] rounded-md border" >
< div className = "p-2 space-y-0.5" >
{ ! selectedProjectId ? (
< p className = "text-sm text-muted-foreground text-center py-8" >
Select a project first
< / p >
) : filteredJurors . length === 0 ? (
< p className = "text-sm text-muted-foreground text-center py-8" >
No jurors found
< / p >
) : (
filteredJurors . map ( ( juror : any ) = > {
const alreadyAssigned = projectExistingJurorIds . has ( juror . id )
const isSelected = selectedJurorIds . has ( juror . id )
const assignCount = jurorAssignmentCounts . get ( juror . id ) ? ? 0
return (
< label
key = { juror . id }
className = { cn (
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors' ,
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50' ,
) }
>
< Checkbox
checked = { isSelected }
disabled = { alreadyAssigned }
onCheckedChange = { ( ) = > toggleJuror ( juror . id ) }
/ >
< div className = "flex flex-1 items-center justify-between min-w-0" >
< div className = "min-w-0" >
< span className = "font-medium truncate block" > { juror . name || 'Unnamed' } < / span >
< span className = "text-xs text-muted-foreground truncate block" > { juror . email } < / span >
< / div >
< div className = "flex items-center gap-1.5 shrink-0 ml-2" >
< Badge variant = "secondary" className = "text-[10px]" >
{ assignCount } assigned
< / Badge >
{ alreadyAssigned && (
< Badge variant = "outline" className = "text-[10px] bg-amber-50 text-amber-700 border-amber-200" >
Already on project
< / Badge >
) }
< / div >
< / div >
< / label >
)
} )
) }
< / div >
< / ScrollArea >
< / div >
< DialogFooter >
< Button variant = "outline" onClick = { resetDialog } >
Cancel
< / Button >
< Button
onClick = { handleCreateByProject }
disabled = { ! selectedProjectId || selectedJurorIds . size === 0 || isMutating }
>
{ isMutating && < Loader2 className = "h-4 w-4 mr-1.5 animate-spin" / > }
{ selectedJurorIds . size <= 1
? 'Create Assignment'
: ` Create ${ selectedJurorIds . size } Assignments `
}
< / Button >
< / DialogFooter >
< / TabsContent >
< / Tabs >
< / DialogContent >
< / Dialog >
{ /* Confirmation AlertDialog for reset/delete */ }
< AlertDialog open = { ! ! confirmAction } onOpenChange = { ( open ) = > { if ( ! open ) setConfirmAction ( null ) } } >
< AlertDialogContent >
< AlertDialogHeader >
< AlertDialogTitle >
{ confirmAction ? . type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?' }
< / AlertDialogTitle >
< AlertDialogDescription >
{ confirmAction ? . type === 'reset'
? ` Reset evaluation by ${ confirmAction . assignment ? . user ? . name || confirmAction . assignment ? . user ? . email } for " ${ confirmAction . assignment ? . project ? . title } "? This will erase all scores and feedback so they can start over. `
: ` Remove assignment for ${ confirmAction ? . assignment ? . user ? . name || confirmAction ? . assignment ? . user ? . email } on " ${ confirmAction ? . assignment ? . project ? . title } "? `
}
< / AlertDialogDescription >
< / AlertDialogHeader >
< AlertDialogFooter >
< AlertDialogCancel > Cancel < / AlertDialogCancel >
< AlertDialogAction
className = { confirmAction ? . type === 'delete' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : '' }
onClick = { ( ) = > {
if ( confirmAction ? . type === 'reset' ) {
resetEvalMutation . mutate ( { assignmentId : confirmAction.assignment.id } )
} else if ( confirmAction ? . type === 'delete' ) {
deleteMutation . mutate ( { id : confirmAction.assignment.id } )
}
setConfirmAction ( null )
} }
>
{ confirmAction ? . type === 'reset' ? 'Reset' : 'Delete' }
< / AlertDialogAction >
< / AlertDialogFooter >
< / AlertDialogContent >
< / AlertDialog >
< / div >
)
}