Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
545
src/app/(admin)/admin/rounds/[id]/assignments/page.tsx
Normal file
545
src/app/(admin)/admin/rounds/[id]/assignments/page.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
|
||||
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
|
||||
const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery(
|
||||
{ roundId, maxPerJuror: 10, minPerProject: 3 },
|
||||
{ enabled: !!round }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const deleteAssignment = trpc.assignment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByRound.invalidate({ roundId })
|
||||
utils.assignment.getStats.invalidate({ roundId })
|
||||
},
|
||||
})
|
||||
|
||||
const applySuggestions = trpc.assignment.applySuggestions.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByRound.invalidate({ roundId })
|
||||
utils.assignment.getStats.invalidate({ roundId })
|
||||
utils.assignment.getSuggestions.invalidate({ roundId })
|
||||
setSelectedSuggestions(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
if (loadingRound || loadingAssignments) {
|
||||
return <AssignmentsSkeleton />
|
||||
}
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Round Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds">Back to Rounds</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleSuggestion = (key: string) => {
|
||||
setSelectedSuggestions((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(key)) {
|
||||
newSet.delete(key)
|
||||
} else {
|
||||
newSet.add(key)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAllSuggestions = () => {
|
||||
if (suggestions) {
|
||||
if (selectedSuggestions.size === suggestions.length) {
|
||||
setSelectedSuggestions(new Set())
|
||||
} else {
|
||||
setSelectedSuggestions(
|
||||
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplySelected = async () => {
|
||||
if (!suggestions) return
|
||||
|
||||
const selected = suggestions.filter((s) =>
|
||||
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
|
||||
)
|
||||
|
||||
await applySuggestions.mutateAsync({
|
||||
roundId,
|
||||
assignments: selected.map((s) => ({
|
||||
userId: s.userId,
|
||||
projectId: s.projectId,
|
||||
reasoning: s.reasoning.join('; '),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Group assignments by project
|
||||
const assignmentsByProject = assignments?.reduce((acc, assignment) => {
|
||||
const projectId = assignment.project.id
|
||||
if (!acc[projectId]) {
|
||||
acc[projectId] = {
|
||||
project: assignment.project,
|
||||
assignments: [],
|
||||
}
|
||||
}
|
||||
acc[projectId].assignments.push(assignment)
|
||||
return acc
|
||||
}, {} as Record<string, { project: (typeof assignments)[0]['project'], assignments: typeof assignments }>) || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
||||
{round.program.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Manage Assignments
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Assignments</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalAssignments}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.completedAssignments}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.completionPercentage}% complete
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects Covered</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.projectsWithFullCoverage}/{stats.totalProjects}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.coveragePercentage}% have {round.requiredReviews}+ reviews
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.juryMembersAssigned}</div>
|
||||
<p className="text-xs text-muted-foreground">assigned to projects</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Progress */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Coverage</CardTitle>
|
||||
<CardDescription>
|
||||
{stats.projectsWithFullCoverage} of {stats.totalProjects} projects have
|
||||
at least {round.requiredReviews} reviewers assigned
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={stats.coveragePercentage} className="h-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
Smart Assignment Suggestions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
AI-powered recommendations based on expertise matching and workload
|
||||
balance
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchSuggestions()}
|
||||
disabled={loadingSuggestions}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingSuggestions ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : suggestions && suggestions.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedSuggestions.size === suggestions.length}
|
||||
onCheckedChange={handleSelectAllSuggestions}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedSuggestions.size} of {suggestions.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleApplySelected}
|
||||
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
|
||||
>
|
||||
{applySuggestions.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Apply Selected ({selectedSuggestions.size})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Reasoning</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{suggestions.map((suggestion) => {
|
||||
const key = `${suggestion.userId}-${suggestion.projectId}`
|
||||
const isSelected = selectedSuggestions.has(key)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={isSelected ? 'bg-muted/50' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSuggestion(key)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{suggestion.userId.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{suggestion.projectId.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
suggestion.score >= 60
|
||||
? 'default'
|
||||
: suggestion.score >= 40
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{suggestion.score.toFixed(0)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs">
|
||||
<ul className="text-xs text-muted-foreground">
|
||||
{suggestion.reasoning.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
|
||||
<p className="mt-2 font-medium">All projects are covered!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No additional assignments are needed at this time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Assignments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Assignments</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage existing project assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.keys(assignmentsByProject).length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(assignmentsByProject).map(
|
||||
([projectId, { project, assignments: projectAssignments }]) => (
|
||||
<div key={projectId} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{project.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{projectAssignments.length} reviewer
|
||||
{projectAssignments.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{projectAssignments.length >= round.requiredReviews && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Full coverage
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-4 border-l-2 border-muted space-y-2">
|
||||
{projectAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="flex items-center justify-between py-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">
|
||||
{assignment.user.name || assignment.user.email}
|
||||
</span>
|
||||
{assignment.evaluation?.status === 'SUBMITTED' ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Submitted
|
||||
</Badge>
|
||||
) : assignment.evaluation?.status === 'DRAFT' ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
In Progress
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
assignment.evaluation?.status === 'SUBMITTED'
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Remove Assignment?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove {assignment.user.name || assignment.user.email} from
|
||||
evaluating this project. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
deleteAssignment.mutate({ id: assignment.id })
|
||||
}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Assignments Yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the smart suggestions above or manually assign jury members to
|
||||
projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssignmentsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssignmentManagementPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<AssignmentsSkeleton />}>
|
||||
<AssignmentManagementContent roundId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user