Add manual assignment feature to assignments page

- Added "Manual Assignment" button with dialog
- Select jury member and project from dropdowns
- Shows current assignment counts and prevents duplicates
- Disables full-capacity jurors and already-assigned combinations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-04 09:45:32 +01:00
parent 1b12aa8ccd
commit 68c0ed00e4
1 changed files with 179 additions and 0 deletions

View File

@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
@ -34,6 +35,22 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ArrowLeft,
Users,
@ -46,7 +63,9 @@ import {
Plus,
Trash2,
RefreshCw,
UserPlus,
} from 'lucide-react'
import { toast } from 'sonner'
interface PageProps {
params: Promise<{ id: string }>
@ -54,6 +73,9 @@ interface PageProps {
function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const [manualDialogOpen, setManualDialogOpen] = useState(false)
const [selectedJuror, setSelectedJuror] = useState<string>('')
const [selectedProject, setSelectedProject] = useState<string>('')
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
@ -63,6 +85,18 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
{ enabled: !!round }
)
// Get available jurors for manual assignment
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: manualDialogOpen }
)
// Get projects in this round for manual assignment
const { data: roundProjects } = trpc.project.list.useQuery(
{ roundId, perPage: 500 },
{ enabled: manualDialogOpen }
)
const utils = trpc.useUtils()
const deleteAssignment = trpc.assignment.delete.useMutation({
@ -81,6 +115,33 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
},
})
const createAssignment = trpc.assignment.create.useMutation({
onSuccess: () => {
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')
return
}
createAssignment.mutate({
userId: selectedJuror,
projectId: selectedProject,
roundId,
})
}
if (loadingRound || loadingAssignments) {
return <AssignmentsSkeleton />
}
@ -180,6 +241,124 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
Manage Assignments
</h1>
</div>
{/* Manual Assignment Button */}
<Dialog open={manualDialogOpen} onOpenChange={setManualDialogOpen}>
<DialogTrigger asChild>
<Button>
<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>
<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">
{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>
)}
</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>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setManualDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateManualAssignment}
disabled={!selectedJuror || !selectedProject || createAssignment.isPending}
>
{createAssignment.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Assignment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Stats */}