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 { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -34,6 +35,22 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} 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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -46,7 +63,9 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -54,6 +73,9 @@ interface PageProps {
|
||||||
|
|
||||||
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
|
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: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
|
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
|
||||||
|
|
@ -63,6 +85,18 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
{ enabled: !!round }
|
{ 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 utils = trpc.useUtils()
|
||||||
|
|
||||||
const deleteAssignment = trpc.assignment.delete.useMutation({
|
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) {
|
if (loadingRound || loadingAssignments) {
|
||||||
return <AssignmentsSkeleton />
|
return <AssignmentsSkeleton />
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +241,124 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
Manage Assignments
|
Manage Assignments
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue