Jury dashboard compact layout, assignment redesign, auth fixes
- Jury dashboard: collapse zero-assignment state into single welcome card with inline quick actions; merge completion bar into stats row; tighten spacing - Manual assignment: replace tiny Dialog modal with inline collapsible section featuring searchable juror combobox and multi-select project list with bulk assign - Fix applicant invite URL path (/auth/accept-invite -> /accept-invite) - Add APPLICANT role redirect to /my-submission from root page - Add Applicant label to accept-invite role display - Fix a/an grammar in invitation emails and accept-invite page - Set-password page: use MOPC logo instead of lock icon - Notification bell: remove filter tabs, always show all notifications Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -42,21 +43,20 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
UserPlus,
|
||||
Cpu,
|
||||
Brain,
|
||||
Search,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -195,9 +199,11 @@ interface PageProps {
|
||||
|
||||
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
|
||||
const [manualDialogOpen, setManualDialogOpen] = useState(false)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const [selectedJuror, setSelectedJuror] = useState<string>('')
|
||||
const [selectedProject, setSelectedProject] = useState<string>('')
|
||||
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||
const [projectSearch, setProjectSearch] = useState('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set())
|
||||
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
|
||||
@@ -302,13 +308,13 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
// Get available jurors for manual assignment
|
||||
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: manualDialogOpen }
|
||||
{ enabled: manualOpen }
|
||||
)
|
||||
|
||||
// Get projects in this round for manual assignment
|
||||
const { data: roundProjects } = trpc.project.list.useQuery(
|
||||
{ roundId, perPage: 500 },
|
||||
{ enabled: manualDialogOpen }
|
||||
{ enabled: manualOpen }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -335,26 +341,45 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
utils.assignment.listByRound.invalidate({ roundId })
|
||||
utils.assignment.getStats.invalidate({ roundId })
|
||||
utils.assignment.getSuggestions.invalidate({ roundId })
|
||||
setManualDialogOpen(false)
|
||||
setSelectedJuror('')
|
||||
setSelectedProject('')
|
||||
toast.success('Assignment created successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to create assignment')
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateManualAssignment = () => {
|
||||
if (!selectedJuror || !selectedProject) {
|
||||
toast.error('Please select both a juror and a project')
|
||||
const [bulkAssigning, setBulkAssigning] = useState(false)
|
||||
|
||||
const handleBulkAssign = async () => {
|
||||
if (!selectedJuror || selectedProjects.size === 0) {
|
||||
toast.error('Please select a juror and at least one project')
|
||||
return
|
||||
}
|
||||
createAssignment.mutate({
|
||||
userId: selectedJuror,
|
||||
projectId: selectedProject,
|
||||
roundId,
|
||||
})
|
||||
setBulkAssigning(true)
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
for (const projectId of selectedProjects) {
|
||||
try {
|
||||
await createAssignment.mutateAsync({
|
||||
userId: selectedJuror,
|
||||
projectId,
|
||||
roundId,
|
||||
})
|
||||
successCount++
|
||||
} catch {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
setBulkAssigning(false)
|
||||
setSelectedProjects(new Set())
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} assignment${successCount > 1 ? 's' : ''} created successfully`)
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
toast.error(`${errorCount} assignment${errorCount > 1 ? 's' : ''} failed`)
|
||||
}
|
||||
utils.assignment.listByRound.invalidate({ roundId })
|
||||
utils.assignment.getStats.invalidate({ roundId })
|
||||
utils.assignment.getSuggestions.invalidate({ roundId })
|
||||
}
|
||||
|
||||
if (loadingRound || loadingAssignments) {
|
||||
@@ -457,124 +482,278 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Manual Assignment Button */}
|
||||
<Dialog open={manualDialogOpen} onOpenChange={setManualDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
{/* Manual Assignment Toggle */}
|
||||
<Button
|
||||
variant={manualOpen ? 'secondary' : 'default'}
|
||||
onClick={() => {
|
||||
setManualOpen(!manualOpen)
|
||||
if (!manualOpen) {
|
||||
setSelectedJuror('')
|
||||
setSelectedProjects(new Set())
|
||||
setProjectSearch('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{manualOpen ? (
|
||||
<>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Close
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Manual Assignment
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Manual Assignment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a jury member to evaluate a specific project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Juror Select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="juror">Jury Member</Label>
|
||||
<Select value={selectedJuror} onValueChange={setSelectedJuror}>
|
||||
<SelectTrigger id="juror">
|
||||
<SelectValue placeholder="Select a jury member..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableJurors?.map((juror) => {
|
||||
const maxAllowed = juror.maxAssignments ?? 10
|
||||
const isFull = juror.currentAssignments >= maxAllowed
|
||||
return (
|
||||
<SelectItem key={juror.id} value={juror.id} disabled={isFull}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className={isFull ? 'opacity-50' : ''}>{juror.name || juror.email}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{/* Inline Manual Assignment Section */}
|
||||
{manualOpen && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Manual Assignment
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => setManualOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Select a jury member, then pick projects to assign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Step 1: Juror Picker */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Step 1: Select Jury Member</Label>
|
||||
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={jurorPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selectedJuror && availableJurors ? (
|
||||
(() => {
|
||||
const juror = availableJurors.find(j => j.id === selectedJuror)
|
||||
if (!juror) return 'Select a jury member...'
|
||||
const maxAllowed = juror.maxAssignments ?? 10
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{juror.name || juror.email}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{juror.currentAssignments}/{maxAllowed} assigned
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
{availableJurors?.length === 0 && (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No jury members available
|
||||
</div>
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
'Select a jury member...'
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedJuror && availableJurors && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const juror = availableJurors.find(j => j.id === selectedJuror)
|
||||
if (!juror) return null
|
||||
const available = (juror.maxAssignments ?? 10) - juror.currentAssignments
|
||||
return `${available} assignment slot${available !== 1 ? 's' : ''} available`
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project">Project</Label>
|
||||
<Select value={selectedProject} onValueChange={setSelectedProject}>
|
||||
<SelectTrigger id="project">
|
||||
<SelectValue placeholder="Select a project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roundProjects?.projects.map((project) => {
|
||||
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
|
||||
const isAlreadyAssigned = selectedJuror && assignments?.some(
|
||||
a => a.userId === selectedJuror && a.projectId === project.id
|
||||
)
|
||||
return (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.id}
|
||||
disabled={!!isAlreadyAssigned}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className={isAlreadyAssigned ? 'line-through opacity-50' : ''}>
|
||||
{project.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{assignmentCount}/{round.requiredReviews} reviewers
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
{roundProjects?.projects.length === 0 && (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
||||
No projects in this round
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search jurors..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No jurors found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableJurors?.map((juror) => {
|
||||
const maxAllowed = juror.maxAssignments ?? 10
|
||||
const isFull = juror.currentAssignments >= maxAllowed
|
||||
return (
|
||||
<CommandItem
|
||||
key={juror.id}
|
||||
value={`${juror.name || ''} ${juror.email}`}
|
||||
onSelect={() => {
|
||||
setSelectedJuror(juror.id === selectedJuror ? '' : juror.id)
|
||||
setSelectedProjects(new Set())
|
||||
setJurorPopoverOpen(false)
|
||||
}}
|
||||
disabled={isFull}
|
||||
className={isFull ? 'opacity-50' : ''}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedJuror === juror.id ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{juror.name || juror.email}</p>
|
||||
{juror.name && (
|
||||
<p className="text-xs text-muted-foreground truncate">{juror.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={isFull ? 'destructive' : 'secondary'} className="text-xs ml-2 shrink-0">
|
||||
{juror.currentAssignments}/{maxAllowed}
|
||||
{isFull && ' Full'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{/* Step 2: Project Multi-Select */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">Step 2: Select Projects</Label>
|
||||
{!selectedJuror ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
Select a jury member first to see available projects
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={projectSearch}
|
||||
onChange={(e) => setProjectSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
const projects = roundProjects?.projects ?? []
|
||||
const filtered = projects.filter(p =>
|
||||
p.title.toLowerCase().includes(projectSearch.toLowerCase())
|
||||
)
|
||||
const unassignedToJuror = filtered.filter(p =>
|
||||
!assignments?.some(a => a.userId === selectedJuror && a.projectId === p.id)
|
||||
)
|
||||
const allUnassignedSelected = unassignedToJuror.length > 0 &&
|
||||
unassignedToJuror.every(p => selectedProjects.has(p.id))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<Checkbox
|
||||
checked={allUnassignedSelected}
|
||||
onCheckedChange={() => {
|
||||
if (allUnassignedSelected) {
|
||||
setSelectedProjects(new Set())
|
||||
} else {
|
||||
setSelectedProjects(new Set(unassignedToJuror.map(p => p.id)))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Select all unassigned ({unassignedToJuror.length})
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[280px] rounded-lg border">
|
||||
<div className="divide-y">
|
||||
{filtered.map((project) => {
|
||||
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
|
||||
const isAlreadyAssigned = assignments?.some(
|
||||
a => a.userId === selectedJuror && a.projectId === project.id
|
||||
)
|
||||
const isFullCoverage = assignmentCount >= round.requiredReviews
|
||||
const isChecked = selectedProjects.has(project.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 transition-colors',
|
||||
isAlreadyAssigned ? 'opacity-40 bg-muted/30' : 'hover:bg-muted/50',
|
||||
isChecked && !isAlreadyAssigned && 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={!!isAlreadyAssigned}
|
||||
onCheckedChange={() => {
|
||||
setSelectedProjects(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(project.id)) {
|
||||
next.delete(project.id)
|
||||
} else {
|
||||
next.add(project.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
isAlreadyAssigned && 'line-through'
|
||||
)}>
|
||||
{project.title}
|
||||
</span>
|
||||
<Badge
|
||||
variant={isFullCoverage ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'text-xs shrink-0',
|
||||
isFullCoverage && 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400 border-0',
|
||||
isAlreadyAssigned && 'bg-muted text-muted-foreground border-0'
|
||||
)}
|
||||
>
|
||||
{isAlreadyAssigned ? (
|
||||
<>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Assigned
|
||||
</>
|
||||
) : isFullCoverage ? (
|
||||
<>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
{assignmentCount}/{round.requiredReviews}
|
||||
</>
|
||||
) : (
|
||||
`${assignmentCount}/${round.requiredReviews} reviewers`
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No projects match your search
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign Button */}
|
||||
{selectedJuror && selectedProjects.size > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setManualDialogOpen(false)}
|
||||
onClick={handleBulkAssign}
|
||||
disabled={bulkAssigning}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateManualAssignment}
|
||||
disabled={!selectedJuror || !selectedProject || createAssignment.isPending}
|
||||
>
|
||||
{createAssignment.isPending && (
|
||||
{bulkAssigning ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Assignment
|
||||
Assign {selectedProjects.size} Project{selectedProjects.size > 1 ? 's' : ''}
|
||||
{availableJurors && (() => {
|
||||
const juror = availableJurors.find(j => j.id === selectedJuror)
|
||||
return juror ? ` to ${juror.name || juror.email}` : ''
|
||||
})()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
|
||||
Reference in New Issue
Block a user