Admin dashboard & round management UX overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s
- Extract round detail monolith (2900→600 lines) into 13 standalone components - Add shared round/status config (round-config.ts) replacing 4 local copies - Delete 12 legacy competition-scoped pages, merge project pool into projects page - Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary) - Add contextual header quick actions based on active round type - Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix - Add config tab completion dots (green/amber/red) and inline validation warnings - Enhance juries page with round assignments, member avatars, and cap mode badges - Add context-aware project list (recent submissions vs active evaluations) - Move competition settings into Manage Editions page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
170
src/components/admin/assignment/coi-review-section.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ShieldAlert, Eye, CheckCircle2, UserPlus, FileText } from 'lucide-react'
|
||||
|
||||
export type COIReviewSectionProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function COIReviewSection({ roundId }: COIReviewSectionProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.evaluation.listCOIByStage.invalidate({ roundId })
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
if (data.reassignment) {
|
||||
toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
|
||||
} else {
|
||||
toast.success('COI review updated')
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Show placeholder when no declarations
|
||||
if (!isLoading && (!declarations || declarations.length === 0)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">No conflict of interest declarations yet.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
|
||||
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Conflict of Interest Declarations
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||
{conflictCount > 0 && (
|
||||
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||
)}
|
||||
{unreviewedCount > 0 && (
|
||||
<> ({unreviewedCount} pending review)</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] 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>Conflict</span>
|
||||
<span>Type</span>
|
||||
<span>Action</span>
|
||||
</div>
|
||||
{declarations?.map((coi: any, idx: number) => (
|
||||
<div
|
||||
key={coi.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
|
||||
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.hasConflict
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
)}
|
||||
>
|
||||
{coi.hasConflict ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
|
||||
</span>
|
||||
{coi.hasConflict ? (
|
||||
coi.reviewedAt ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.reviewAction === 'cleared'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: coi.reviewAction === 'reassigned'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||
)}
|
||||
>
|
||||
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
|
||||
</Badge>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Review
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
|
||||
Clear — no real conflict
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||
Reassign to another juror
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
|
||||
Note — keep as is
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
854
src/components/admin/assignment/individual-assignments-table.tsx
Normal file
@@ -0,0 +1,854 @@
|
||||
'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_100px_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>Actions</span>
|
||||
</div>
|
||||
{assignments.map((a: any, idx: number) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_100px_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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
180
src/components/admin/assignment/jury-progress-table.tsx
Normal file
180
src/components/admin/assignment/jury-progress-table.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Loader2, Mail, ArrowRightLeft, UserPlus } from 'lucide-react'
|
||||
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
|
||||
|
||||
export type JuryProgressTableProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function JuryProgressTable({ roundId }: JuryProgressTableProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
|
||||
if (data.failedCount > 0) {
|
||||
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
|
||||
} else {
|
||||
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Jury Progress</CardTitle>
|
||||
<CardDescription>Evaluation completion per juror. Click the mail icon to notify an individual juror.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !workload || workload.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No assignments yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[350px] overflow-y-auto">
|
||||
{workload.map((juror) => {
|
||||
const pct = juror.completionRate
|
||||
const barGradient = pct === 100
|
||||
? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
|
||||
: pct >= 50
|
||||
? 'bg-gradient-to-r from-blue-400 to-blue-600'
|
||||
: pct > 0
|
||||
? 'bg-gradient-to-r from-amber-400 to-amber-600'
|
||||
: 'bg-gray-300'
|
||||
|
||||
return (
|
||||
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="font-medium truncate max-w-[50%]">{juror.name}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{juror.completed}/{juror.assigned} ({pct}%)
|
||||
</span>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
disabled={notifyMutation.isPending}
|
||||
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
|
||||
>
|
||||
{notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||
disabled={reshuffleMutation.isPending}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
`Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
|
||||
)
|
||||
if (!ok) return
|
||||
reshuffleMutation.mutate({ roundId, jurorId: juror.id })
|
||||
}}
|
||||
>
|
||||
{reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barGradient)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{transferJuror && (
|
||||
<TransferAssignmentsDialog
|
||||
roundId={roundId}
|
||||
sourceJuror={transferJuror}
|
||||
open={!!transferJuror}
|
||||
onClose={() => setTransferJuror(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
61
src/components/admin/assignment/notify-jurors-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Mail, Loader2 } from 'lucide-react'
|
||||
|
||||
export type NotifyJurorsButtonProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function NotifyJurorsButton({ roundId }: NotifyJurorsButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Mail className="h-4 w-4 mr-1.5" />
|
||||
Notify Jurors
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Notify jurors of their assignments?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mutation.mutate({ roundId })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Notify Jurors
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
src/components/admin/assignment/reassignment-history.tsx
Normal file
111
src/components/admin/assignment/reassignment-history.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { History, ChevronRight } from 'lucide-react'
|
||||
|
||||
export type ReassignmentHistoryProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: expanded },
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Reassignment History
|
||||
<ChevronRight className={cn('h-4 w-4 ml-auto transition-transform', expanded && 'rotate-90')} />
|
||||
</CardTitle>
|
||||
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
|
||||
</div>
|
||||
) : !events || events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
No reassignment events for this round
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="border rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
|
||||
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{event.droppedJuror.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By {event.performedBy.name || event.performedBy.email} — {event.movedCount} project(s) reassigned
|
||||
{event.failedCount > 0 && `, ${event.failedCount} failed`}
|
||||
</p>
|
||||
|
||||
{event.moves.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b">
|
||||
<th className="text-left py-1 font-medium">Project</th>
|
||||
<th className="text-left py-1 font-medium">Reassigned To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.moves.map((move, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-2 max-w-[250px] truncate">
|
||||
{move.projectTitle}
|
||||
</td>
|
||||
<td className="py-1.5 font-medium">
|
||||
{move.newJurorName}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.failedProjects.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs font-medium text-destructive">Could not reassign:</p>
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{event.failedProjects.map((p, i) => (
|
||||
<li key={i}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
66
src/components/admin/assignment/round-unassigned-queue.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export type RoundUnassignedQueueProps = {
|
||||
roundId: string
|
||||
requiredReviews?: number
|
||||
}
|
||||
|
||||
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
|
||||
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId, requiredReviews },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Unassigned Projects</p>
|
||||
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
|
||||
</div>
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : unassigned && unassigned.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{unassigned.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex justify-between items-center p-3 border rounded-md hover:bg-muted/30 transition-colors',
|
||||
(project.assignmentCount || 0) === 0 && 'border-l-4 border-l-red-500',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.teamName && ` \u00b7 ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs shrink-0 ml-3',
|
||||
(project.assignmentCount || 0) === 0
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
)}>
|
||||
{project.assignmentCount || 0} / {requiredReviews}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
61
src/components/admin/assignment/send-reminders-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Send, Loader2 } from 'lucide-react'
|
||||
|
||||
export type SendRemindersButtonProps = {
|
||||
roundId: string
|
||||
}
|
||||
|
||||
export function SendRemindersButton({ roundId }: SendRemindersButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const mutation = trpc.evaluation.triggerReminders.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Sent ${data.sent} reminder(s)`)
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Send className="h-4 w-4 mr-1.5" />
|
||||
Send Reminders
|
||||
</Button>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Send evaluation reminders?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will send reminder emails to all jurors who have incomplete evaluations for this round.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mutation.mutate({ roundId })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Send Reminders
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
328
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
328
src/components/admin/assignment/transfer-assignments-dialog.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Loader2, Sparkles } from 'lucide-react'
|
||||
|
||||
export type TransferAssignmentsDialogProps = {
|
||||
roundId: string
|
||||
sourceJuror: { id: string; name: string }
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TransferAssignmentsDialog({
|
||||
roundId,
|
||||
sourceJuror,
|
||||
open,
|
||||
onClose,
|
||||
}: TransferAssignmentsDialogProps) {
|
||||
const utils = trpc.useUtils()
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch source juror's assignments
|
||||
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: open },
|
||||
)
|
||||
|
||||
const jurorAssignments = useMemo(() =>
|
||||
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
|
||||
[sourceAssignments, sourceJuror.id],
|
||||
)
|
||||
|
||||
// Fetch transfer candidates when in step 2
|
||||
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
|
||||
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
|
||||
{ enabled: step === 2 && selectedIds.size > 0 },
|
||||
)
|
||||
|
||||
// Per-assignment destination overrides
|
||||
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
|
||||
const [forceOverCap, setForceOverCap] = useState(false)
|
||||
|
||||
// Auto-assign: distribute assignments across eligible candidates balanced by load
|
||||
const handleAutoAssign = () => {
|
||||
if (!candidateData) return
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
if (movable.length === 0) return
|
||||
|
||||
// Simulate load starting from each candidate's current load
|
||||
const simLoad = new Map<string, number>()
|
||||
for (const c of candidateData.candidates) {
|
||||
simLoad.set(c.userId, c.currentLoad)
|
||||
}
|
||||
|
||||
const overrides: Record<string, string> = {}
|
||||
|
||||
for (const assignment of movable) {
|
||||
const eligible = candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
|
||||
if (eligible.length === 0) continue
|
||||
|
||||
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
|
||||
const sorted = [...eligible].sort((a, b) => {
|
||||
// Prefer jurors who haven't completed all evaluations
|
||||
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
|
||||
const loadA = simLoad.get(a.userId) ?? 0
|
||||
const loadB = simLoad.get(b.userId) ?? 0
|
||||
// Prefer jurors under their cap
|
||||
const overCapA = loadA >= a.cap ? 1 : 0
|
||||
const overCapB = loadB >= b.cap ? 1 : 0
|
||||
if (overCapA !== overCapB) return overCapA - overCapB
|
||||
// Then pick the least loaded
|
||||
return loadA - loadB
|
||||
})
|
||||
|
||||
const best = sorted[0]
|
||||
overrides[assignment.id] = best.userId
|
||||
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
setDestOverrides(overrides)
|
||||
}
|
||||
|
||||
const transferMutation = trpc.assignment.transferAssignments.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.analytics.getJurorWorkload.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
utils.assignment.getReassignmentHistory.invalidate({ roundId })
|
||||
|
||||
const successCount = data.succeeded.length
|
||||
const failCount = data.failed.length
|
||||
if (failCount > 0) {
|
||||
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
|
||||
} else {
|
||||
toast.success(`Transferred ${successCount} project(s) successfully.`)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Build the transfer plan: for each selected assignment, determine destination
|
||||
const transferPlan = useMemo(() => {
|
||||
if (!candidateData) return []
|
||||
const movable = candidateData.assignments.filter((a) => a.movable)
|
||||
return movable.map((assignment) => {
|
||||
const override = destOverrides[assignment.id]
|
||||
// Default: first eligible candidate
|
||||
const defaultDest = candidateData.candidates.find((c) =>
|
||||
c.eligibleProjectIds.includes(assignment.projectId)
|
||||
)
|
||||
const destId = override || defaultDest?.userId || ''
|
||||
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
|
||||
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
|
||||
}).filter((t) => t.destinationJurorId)
|
||||
}, [candidateData, destOverrides])
|
||||
|
||||
// Check if any destination is at or over cap
|
||||
const anyOverCap = useMemo(() => {
|
||||
if (!candidateData) return false
|
||||
const destCounts = new Map<string, number>()
|
||||
for (const t of transferPlan) {
|
||||
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
|
||||
}
|
||||
return candidateData.candidates.some((c) => {
|
||||
const extraLoad = destCounts.get(c.userId) ?? 0
|
||||
return c.currentLoad + extraLoad > c.cap
|
||||
})
|
||||
}, [candidateData, transferPlan])
|
||||
|
||||
const handleTransfer = () => {
|
||||
transferMutation.mutate({
|
||||
roundId,
|
||||
sourceJurorId: sourceJuror.id,
|
||||
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
|
||||
forceOverCap,
|
||||
})
|
||||
}
|
||||
|
||||
const isMovable = (a: any) => {
|
||||
const status = a.evaluation?.status
|
||||
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
|
||||
}
|
||||
|
||||
const movableAssignments = jurorAssignments.filter(isMovable)
|
||||
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
{loadingAssignments ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : jurorAssignments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Checkbox
|
||||
checked={allMovableSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
|
||||
} else {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{jurorAssignments.map((a: any) => {
|
||||
const movable = isMovable(a)
|
||||
const status = a.evaluation?.status || 'No evaluation'
|
||||
return (
|
||||
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(a.id)}
|
||||
disabled={!movable}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(selectedIds)
|
||||
if (checked) next.add(a.id)
|
||||
else next.delete(a.id)
|
||||
setSelectedIds(next)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => { setStep(2); setDestOverrides({}) }}
|
||||
>
|
||||
Next ({selectedIds.size} selected)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
{loadingCandidates ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : !candidateData || candidateData.candidates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
Auto-assign
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
|
||||
const currentDest = destOverrides[assignment.id] ||
|
||||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
|
||||
return (
|
||||
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{assignment.projectTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={currentDest}
|
||||
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] h-8 text-xs">
|
||||
<SelectValue placeholder="Select juror" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidateData.candidates
|
||||
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>
|
||||
<span>{c.name}</span>
|
||||
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
|
||||
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{transferPlan.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{anyOverCap && (
|
||||
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
|
||||
<Checkbox
|
||||
checked={forceOverCap}
|
||||
onCheckedChange={(checked) => setForceOverCap(!!checked)}
|
||||
/>
|
||||
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
|
||||
<Button
|
||||
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
|
||||
onClick={handleTransfer}
|
||||
>
|
||||
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
||||
Transfer {transferPlan.length} project(s)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user