'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 && (
)}
))}
)}
) : (
)}
)
}
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 */}
}>
)
}