'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 } if (!programs || programs.length === 0) { return (

No Programs Found

Create a program first to start managing rounds

) } return (
{programs.map((program) => ( ))}
) } function ProgramRounds({ program }: { program: any }) { const utils = trpc.useUtils() const [rounds, setRounds] = useState(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 (
{program.year} Edition {program.name} - {program.status}
{rounds.length > 0 ? (
{/* Desktop: Table header */}
Order
Round
Status
Voting Window
Projects
Reviewers
{/* Sortable List */} r.id)} strategy={verticalListSortingStrategy} >
{rounds.map((round, index) => ( ))}
{/* Flow visualization */} {rounds.length > 1 && (

Project Flow

{rounds.map((round, index) => (
{index} {round.name} {round._count?.projects || 0}
{index < rounds.length - 1 && ( )}
))}
)}
) : (

No rounds created yet

)}
) } 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 ( Voting Open ) } switch (round.status) { case 'DRAFT': return Draft case 'ACTIVE': return ( Active ) case 'CLOSED': return Closed case 'ARCHIVED': return ( Archived ) default: return {round.status} } } const getVotingWindow = () => { if (!round.votingStartAt || !round.votingEndAt) { return Not set } const start = new Date(round.votingStartAt) const end = new Date(round.votingEndAt) if (isFuture(start)) { return ( Opens {format(start, 'MMM d')} ) } if (isPast(end)) { return ( Ended {format(end, 'MMM d')} ) } return ( Until {format(end, 'MMM d')} ) } const actionsMenu = ( View Details Edit Round Manage Judge Assignments {round.status === 'DRAFT' && ( updateStatus.mutate({ id: round.id, status: 'ACTIVE' }) } > Activate Round )} {round.status === 'ACTIVE' && ( updateStatus.mutate({ id: round.id, status: 'CLOSED' }) } > Close Round )} {round.status === 'CLOSED' && ( updateStatus.mutate({ id: round.id, status: 'ARCHIVED' }) } > Archive Round )} setShowDeleteDialog(true)} > Delete Round ) const deleteDialog = ( Delete Round Are you sure you want to delete "{round.name}"? 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. Cancel deleteRound.mutate({ id: round.id })} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {deleteRound.isPending ? ( ) : null} Delete ) return (
{/* Desktop: Table row layout */}
{/* Order number with drag handle */}
{index}
{/* Round name */}
{round.name}

{round.roundType?.toLowerCase().replace('_', ' ')}

{/* Status */}
{getStatusBadge()}
{/* Voting window */}
{getVotingWindow()}
{/* Projects */}
{round._count?.projects || 0}
{/* Assignments */}
{round._count?.assignments || 0}
{/* Actions */}
{actionsMenu}
{/* Mobile/Tablet: Card layout */}
{/* Top row: drag handle, order, name, status badge, actions */}
{index}
{round.name}

{round.roundType?.toLowerCase().replace('_', ' ')}

{getStatusBadge()} {actionsMenu}
{/* Details row */}

Voting Window

{getVotingWindow()}

Projects

{round._count?.projects || 0}

Reviewers

{round._count?.assignments || 0}
{deleteDialog}
) } function RoundsListSkeleton() { return (
{[1, 2].map((i) => (
{/* Desktop skeleton */}
{[1, 2, 3].map((j) => (
))}
{/* Mobile/Tablet skeleton */}
{[1, 2, 3].map((j) => (
))}
))}
) } export default function RoundsPage() { return (
{/* Header */}

Rounds

Manage selection rounds and voting periods

{/* Content */} }>
) }