546 lines
20 KiB
TypeScript
546 lines
20 KiB
TypeScript
|
|
'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>
|
||
|
|
)
|
||
|
|
}
|