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:
parent
1b12aa8ccd
commit
68c0ed00e4
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue