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

683 lines
21 KiB
TypeScript

'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
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 {
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 {
Plus,
MoreHorizontal,
Eye,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
Archive,
Trash2,
Loader2,
GripVertical,
ArrowRight,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
type RoundData = {
id: string
name: string
status: string
roundType: string
votingStartAt: string | null
votingEndAt: string | null
_count?: {
projects: number
assignments: number
}
}
function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true,
})
if (isLoading) {
return <RoundsListSkeleton />
}
if (!programs || programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first to start managing rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{programs.map((program) => (
<ProgramRounds key={program.id} program={program} />
))}
</div>
)
}
function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils()
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const reorder = trpc.round.reorder.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
toast.success('Round order updated')
},
onError: (error) => {
toast.error(error.message || 'Failed to reorder rounds')
// Reset to original order on error
setRounds(program.rounds || [])
},
})
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = rounds.findIndex((r) => r.id === active.id)
const newIndex = rounds.findIndex((r) => r.id === over.id)
const newRounds = arrayMove(rounds, oldIndex, newIndex)
setRounds(newRounds)
// Send the new order to the server
reorder.mutate({
programId: program.id,
roundIds: newRounds.map((r) => r.id),
})
}
}
// Sync local state when program.rounds changes
if (JSON.stringify(rounds.map(r => r.id)) !== JSON.stringify((program.rounds || []).map((r: RoundData) => r.id))) {
setRounds(program.rounds || [])
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{rounds.length > 0 ? (
<div className="space-y-2">
{/* Desktop: Table header */}
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div>Order</div>
<div>Round</div>
<div>Status</div>
<div>Voting Window</div>
<div>Projects</div>
<div>Reviewers</div>
<div></div>
</div>
{/* Sortable List */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 lg:space-y-1">
{rounds.map((round, index) => (
<SortableRoundRow
key={round.id}
round={round}
index={index}
totalRounds={rounds.length}
isReordering={reorder.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Flow visualization */}
{rounds.length > 1 && (
<div className="mt-6 pt-4 border-t">
<p className="text-xs text-muted-foreground mb-3 uppercase tracking-wide font-medium">
Project Flow
</p>
<div className="flex items-center gap-2 flex-wrap">
{rounds.map((round, index) => (
<div key={round.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-muted/50 rounded-lg px-3 py-1.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-bold">
{index}
</span>
<span className="text-sm font-medium truncate max-w-[120px]">
{round.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.projects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
)}
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
)
}
function SortableRoundRow({
round,
index,
totalRounds,
isReordering,
}: {
round: RoundData
index: number
totalRounds: number
isReordering: boolean
}) {
const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => {
toast.success('Round deleted successfully')
utils.program.list.invalidate({ includeRounds: true })
},
onError: (error) => {
toast.error(error.message || 'Failed to delete round')
},
})
const getStatusBadge = () => {
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
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>
}
}
const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground text-sm">Not set</span>
}
const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt)
if (isFuture(start)) {
return (
<span className="text-sm">
Opens {format(start, 'MMM d')}
</span>
)
}
if (isPast(end)) {
return (
<span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d')}
</span>
)
}
return (
<span className="text-sm">
Until {format(end, 'MMM d')}
</span>
)
}
const actionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Judge Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Round
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ id: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteRound.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Desktop: Table row layout */}
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
{/* Order number with drag handle */}
<div className="flex items-center gap-1">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
{/* Round name */}
<div>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
{/* Actions */}
<div>
{actionsMenu}
</div>
</div>
{/* Mobile/Tablet: Card layout */}
<div className="lg:hidden p-4">
{/* Top row: drag handle, order, name, status badge, actions */}
<div className="flex items-start gap-3">
<div className="flex items-center gap-1 pt-0.5">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline line-clamp-1"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{getStatusBadge()}
{actionsMenu}
</div>
</div>
</div>
</div>
{/* Details row */}
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Voting Window</p>
<div className="mt-0.5">{getVotingWindow()}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Projects</p>
<div className="flex items-center gap-1.5 mt-0.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Reviewers</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
</div>
</div>
</div>
{deleteDialog}
</div>
)
}
function RoundsListSkeleton() {
return (
<div className="space-y-6">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-10 w-28" />
</div>
</CardHeader>
<CardContent>
{/* Desktop skeleton */}
<div className="hidden lg:block space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
{/* Mobile/Tablet skeleton */}
<div className="lg:hidden space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-6 w-16" />
</div>
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function RoundsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
<p className="text-muted-foreground">
Manage selection rounds and voting periods
</p>
</div>
</div>
{/* Content */}
<Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent />
</Suspense>
</div>
)
}