529 lines
18 KiB
TypeScript
529 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, use, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
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 { Separator } from '@/components/ui/separator'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
import {
|
|
ArrowLeft,
|
|
Edit,
|
|
Users,
|
|
FileText,
|
|
Calendar,
|
|
CheckCircle2,
|
|
Clock,
|
|
AlertCircle,
|
|
Archive,
|
|
Play,
|
|
Pause,
|
|
BarChart3,
|
|
Upload,
|
|
Filter,
|
|
Trash2,
|
|
Loader2,
|
|
Plus,
|
|
ArrowRightCircle,
|
|
Minus,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
|
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
|
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
|
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>
|
|
}
|
|
|
|
function RoundDetailContent({ roundId }: { roundId: string }) {
|
|
const router = useRouter()
|
|
const [assignOpen, setAssignOpen] = useState(false)
|
|
const [advanceOpen, setAdvanceOpen] = useState(false)
|
|
const [removeOpen, setRemoveOpen] = useState(false)
|
|
|
|
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
|
|
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
|
|
|
const utils = trpc.useUtils()
|
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
|
onSuccess: () => {
|
|
utils.round.get.invalidate({ id: roundId })
|
|
},
|
|
})
|
|
const deleteRound = trpc.round.delete.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Round deleted')
|
|
router.push('/admin/rounds')
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to delete round')
|
|
},
|
|
})
|
|
|
|
if (isLoading) {
|
|
return <RoundDetailSkeleton />
|
|
}
|
|
|
|
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 now = new Date()
|
|
const isVotingOpen =
|
|
round.status === 'ACTIVE' &&
|
|
round.votingStartAt &&
|
|
round.votingEndAt &&
|
|
new Date(round.votingStartAt) <= now &&
|
|
new Date(round.votingEndAt) >= now
|
|
|
|
const getStatusBadge = () => {
|
|
if (round.status === 'ACTIVE' && isVotingOpen) {
|
|
return (
|
|
<Badge variant="default" className="bg-green-600">
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
Voting Open
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
switch (round.status) {
|
|
case 'DRAFT':
|
|
return <Badge variant="secondary">Draft</Badge>
|
|
case 'ACTIVE':
|
|
return (
|
|
<Badge variant="default">
|
|
<Clock className="mr-1 h-3 w-3" />
|
|
Active
|
|
</Badge>
|
|
)
|
|
case 'CLOSED':
|
|
return <Badge variant="outline">Closed</Badge>
|
|
case 'ARCHIVED':
|
|
return (
|
|
<Badge variant="outline">
|
|
<Archive className="mr-1 h-3 w-3" />
|
|
Archived
|
|
</Badge>
|
|
)
|
|
default:
|
|
return <Badge variant="secondary">{round.status}</Badge>
|
|
}
|
|
}
|
|
|
|
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">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Rounds
|
|
</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.year} Edition
|
|
</Link>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
|
|
{getStatusBadge()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/rounds/${round.id}/edit`}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Edit
|
|
</Link>
|
|
</Button>
|
|
{round.status === 'DRAFT' && (
|
|
<Button
|
|
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
|
|
disabled={updateStatus.isPending}
|
|
>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
Activate
|
|
</Button>
|
|
)}
|
|
{round.status === 'ACTIVE' && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => updateStatus.mutate({ id: round.id, status: 'CLOSED' })}
|
|
disabled={updateStatus.isPending}
|
|
>
|
|
<Pause className="mr-2 h-4 w-4" />
|
|
Close Round
|
|
</Button>
|
|
)}
|
|
{round.status === 'DRAFT' && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will permanently delete “{round.name}” and all
|
|
associated projects, assignments, and evaluations. This action
|
|
cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteRound.mutate({ id: round.id })}
|
|
disabled={deleteRound.isPending}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{deleteRound.isPending ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Deleting...
|
|
</>
|
|
) : (
|
|
'Delete Round'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Stats Grid */}
|
|
<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">Projects</CardTitle>
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{round._count.roundProjects}</div>
|
|
<Button variant="link" size="sm" className="px-0" asChild>
|
|
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{round._count.assignments}</div>
|
|
<Button variant="link" size="sm" className="px-0" asChild>
|
|
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
|
Manage assignments
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{round.requiredReviews}</div>
|
|
<p className="text-xs text-muted-foreground">per project</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{progress?.completionPercentage || 0}%
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
{progress && progress.totalAssignments > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center justify-between text-sm mb-2">
|
|
<span>Overall Completion</span>
|
|
<span>{progress.completionPercentage}%</span>
|
|
</div>
|
|
<Progress value={progress.completionPercentage} />
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-4">
|
|
{Object.entries(progress.evaluationsByStatus).map(([status, count]) => (
|
|
<div key={status} className="text-center p-3 rounded-lg bg-muted">
|
|
<p className="text-2xl font-bold">{count}</p>
|
|
<p className="text-xs text-muted-foreground capitalize">
|
|
{status.toLowerCase().replace('_', ' ')}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Voting Window */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Voting Window</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">Start Date</p>
|
|
{round.votingStartAt ? (
|
|
<div>
|
|
<p className="font-medium">
|
|
{format(new Date(round.votingStartAt), 'PPP')}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{format(new Date(round.votingStartAt), 'p')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground italic">Not set</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-sm text-muted-foreground mb-1">End Date</p>
|
|
{round.votingEndAt ? (
|
|
<div>
|
|
<p className="font-medium">
|
|
{format(new Date(round.votingEndAt), 'PPP')}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{format(new Date(round.votingEndAt), 'p')}
|
|
</p>
|
|
{isFuture(new Date(round.votingEndAt)) && (
|
|
<p className="text-sm text-amber-600 mt-1">
|
|
Ends {formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground italic">Not set</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Voting status */}
|
|
{round.votingStartAt && round.votingEndAt && (
|
|
<div
|
|
className={`p-4 rounded-lg ${
|
|
isVotingOpen
|
|
? 'bg-green-500/10 text-green-700'
|
|
: isFuture(new Date(round.votingStartAt))
|
|
? 'bg-amber-500/10 text-amber-700'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{isVotingOpen ? (
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
<span className="font-medium">Voting is currently open</span>
|
|
</div>
|
|
) : isFuture(new Date(round.votingStartAt)) ? (
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
<span className="font-medium">
|
|
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<AlertCircle className="h-5 w-5" />
|
|
<span className="font-medium">Voting period has ended</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Quick Actions */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-3">
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/projects/import?round=${round.id}`}>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Import Projects
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
|
<Filter className="mr-2 h-4 w-4" />
|
|
Manage Filtering
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
|
<Users className="mr-2 h-4 w-4" />
|
|
Manage Assignments
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" asChild>
|
|
<Link href={`/admin/projects?round=${round.id}`}>
|
|
<FileText className="mr-2 h-4 w-4" />
|
|
View Projects
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setAssignOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Assign Projects
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setAdvanceOpen(true)}>
|
|
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
|
Advance Projects
|
|
</Button>
|
|
<Button variant="outline" onClick={() => setRemoveOpen(true)}>
|
|
<Minus className="mr-2 h-4 w-4" />
|
|
Remove Projects
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Dialogs */}
|
|
<AssignProjectsDialog
|
|
roundId={roundId}
|
|
programId={round.program.id}
|
|
open={assignOpen}
|
|
onOpenChange={setAssignOpen}
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
/>
|
|
<AdvanceProjectsDialog
|
|
roundId={roundId}
|
|
programId={round.program.id}
|
|
open={advanceOpen}
|
|
onOpenChange={setAdvanceOpen}
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
/>
|
|
<RemoveProjectsDialog
|
|
roundId={roundId}
|
|
open={removeOpen}
|
|
onOpenChange={setRemoveOpen}
|
|
onSuccess={() => utils.round.get.invalidate({ id: roundId })}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RoundDetailSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-9 w-36" />
|
|
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-8 w-64" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-10 w-24" />
|
|
<Skeleton className="h-10 w-28" />
|
|
</div>
|
|
</div>
|
|
|
|
<Skeleton className="h-px w-full" />
|
|
|
|
<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-24" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-16" />
|
|
<Skeleton className="mt-1 h-4 w-20" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-40" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-3 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function RoundDetailPage({ params }: PageProps) {
|
|
const { id } = use(params)
|
|
|
|
return (
|
|
<Suspense fallback={<RoundDetailSkeleton />}>
|
|
<RoundDetailContent roundId={id} />
|
|
</Suspense>
|
|
)
|
|
}
|