- Update listByStage query to include evaluation form criteriaJson and criterionScoresJson - Add Advance column to individual assignments table showing YES/NO badge per submitted evaluation - Create AdvancementSummaryCard component showing yes/no/pending vote counts with stacked bar - Wire AdvancementSummaryCard into the EVALUATION round overview tab Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
870 lines
36 KiB
TypeScript
870 lines
36 KiB
TypeScript
'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">
|
|
<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">
|
|
<span>Juror</span>
|
|
<span>Project</span>
|
|
<span>Status</span>
|
|
<span>Advance</span>
|
|
<span>Actions</span>
|
|
</div>
|
|
{assignments.map((a: any, idx: number) => (
|
|
<div
|
|
key={a.id}
|
|
className={cn(
|
|
'grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
|
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>
|
|
<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>
|
|
<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>
|
|
)
|
|
}
|