MOPC-App/src/app/(admin)/admin/rounds/[id]/page.tsx

727 lines
26 KiB
TypeScript
Raw Normal View History

'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,
XCircle,
AlertTriangle,
ListChecks,
ClipboardCheck,
} 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, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
// Filtering queries (only fetch for FILTERING rounds)
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
const isFilteringRound = roundType === 'FILTERING'
const { data: filteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
const { data: aiStatus } = trpc.filtering.checkAIStatus.useQuery(
{ roundId },
{ enabled: isFilteringRound }
)
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')
utils.program.list.invalidate()
utils.round.list.invalidate()
router.push('/admin/rounds')
},
onError: () => {
toast.error('Failed to delete round')
},
})
// Filtering mutations
const executeRules = trpc.filtering.executeRules.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
const handleExecuteFiltering = async () => {
try {
const result = await executeRules.mutateAsync({ roundId })
toast.success(
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
)
refetchFilteringStats()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to execute filtering'
)
}
}
const handleFinalizeFiltering = async () => {
try {
const result = await finalizeResults.mutateAsync({ roundId })
toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
)
refetchFilteringStats()
refetchRound()
utils.project.list.invalidate()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to finalize'
)
}
}
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 &ldquo;{round.name}&rdquo; 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>
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
Project Filtering
</CardTitle>
<CardDescription>
Run automated screening rules on projects in this round
</CardDescription>
</div>
<div className="flex gap-2">
<Button
onClick={handleExecuteFiltering}
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
>
{executeRules.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
Run Filtering
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* AI Status Warning */}
{aiStatus?.hasAIRules && !aiStatus?.configured && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
<div className="flex-1">
<p className="font-medium text-amber-700">AI Configuration Required</p>
<p className="text-sm text-amber-600">
{aiStatus.error || 'AI screening rules require OpenAI to be configured.'}
</p>
</div>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/settings">Configure AI</Link>
</Button>
</div>
)}
{/* Stats */}
{filteringStats && filteringStats.total > 0 ? (
<div className="grid gap-4 sm:grid-cols-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<Filter className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{filteringStats.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{filteringStats.passed}
</p>
<p className="text-sm text-muted-foreground">Passed</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-red-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/20">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">
{filteringStats.filteredOut}
</p>
<p className="text-sm text-muted-foreground">Filtered Out</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold text-amber-600">
{filteringStats.flagged}
</p>
<p className="text-sm text-muted-foreground">Flagged</p>
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No filtering results yet</p>
<p className="text-sm text-muted-foreground">
Configure rules and run filtering to screen projects
</p>
</div>
)}
{/* Quick links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
<ListChecks className="mr-2 h-4 w-4" />
Configure Rules
<Badge variant="secondary" className="ml-2">
{filteringRules?.length || 0}
</Badge>
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/results`}>
<ClipboardCheck className="mr-2 h-4 w-4" />
Review Results
</Link>
</Button>
{filteringStats && filteringStats.total > 0 && (
<Button
onClick={handleFinalizeFiltering}
disabled={finalizeResults.isPending}
variant="default"
>
{finalizeResults.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Finalize Results
</Button>
)}
</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>
{!isFilteringRound && (
<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>
)
}