2026-01-30 13:41:32 +01:00
|
|
|
'use client'
|
|
|
|
|
|
2026-01-30 19:28:57 +01:00
|
|
|
import { Suspense, useState } from 'react'
|
2026-01-30 13:41:32 +01:00
|
|
|
import Link from 'next/link'
|
|
|
|
|
import { trpc } from '@/lib/trpc/client'
|
2026-01-30 19:28:57 +01:00
|
|
|
import { toast } from 'sonner'
|
2026-02-03 23:19:45 +01:00
|
|
|
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'
|
2026-01-30 13:41:32 +01:00
|
|
|
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'
|
2026-01-30 19:28:57 +01:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from '@/components/ui/alert-dialog'
|
2026-01-30 13:41:32 +01:00
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
MoreHorizontal,
|
|
|
|
|
Eye,
|
|
|
|
|
Edit,
|
|
|
|
|
Users,
|
|
|
|
|
FileText,
|
|
|
|
|
Calendar,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Clock,
|
|
|
|
|
Archive,
|
2026-01-30 19:28:57 +01:00
|
|
|
Trash2,
|
|
|
|
|
Loader2,
|
2026-02-03 23:19:45 +01:00
|
|
|
GripVertical,
|
|
|
|
|
ArrowRight,
|
2026-01-30 13:41:32 +01:00
|
|
|
} from 'lucide-react'
|
|
|
|
|
import { format, isPast, isFuture } from 'date-fns'
|
2026-02-03 23:19:45 +01:00
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
|
|
|
|
|
type RoundData = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
status: string
|
|
|
|
|
roundType: string
|
|
|
|
|
votingStartAt: string | null
|
|
|
|
|
votingEndAt: string | null
|
|
|
|
|
_count?: {
|
2026-02-04 14:15:06 +01:00
|
|
|
projects: number
|
2026-02-03 23:19:45 +01:00
|
|
|
assignments: number
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
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) => (
|
2026-02-03 23:19:45 +01:00
|
|
|
<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">
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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">
|
2026-02-03 23:19:45 +01:00
|
|
|
<div>Order</div>
|
|
|
|
|
<div>Round</div>
|
|
|
|
|
<div>Status</div>
|
|
|
|
|
<div>Voting Window</div>
|
|
|
|
|
<div>Projects</div>
|
|
|
|
|
<div>Reviewers</div>
|
|
|
|
|
<div></div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</div>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
|
|
|
|
{/* Sortable List */}
|
|
|
|
|
<DndContext
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
>
|
|
|
|
|
<SortableContext
|
|
|
|
|
items={rounds.map((r) => r.id)}
|
|
|
|
|
strategy={verticalListSortingStrategy}
|
|
|
|
|
>
|
2026-02-05 20:31:08 +01:00
|
|
|
<div className="space-y-2 lg:space-y-1">
|
2026-02-03 23:19:45 +01:00
|
|
|
{rounds.map((round, index) => (
|
|
|
|
|
<SortableRoundRow
|
2026-02-02 22:33:55 +01:00
|
|
|
key={round.id}
|
|
|
|
|
round={round}
|
|
|
|
|
index={index}
|
2026-02-03 23:19:45 +01:00
|
|
|
totalRounds={rounds.length}
|
|
|
|
|
isReordering={reorder.isPending}
|
2026-02-02 22:33:55 +01:00
|
|
|
/>
|
2026-01-30 13:41:32 +01:00
|
|
|
))}
|
2026-02-03 23:19:45 +01:00
|
|
|
</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">
|
2026-02-04 14:15:06 +01:00
|
|
|
{round._count?.projects || 0}
|
2026-02-03 23:19:45 +01:00
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
{index < rounds.length - 1 && (
|
|
|
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-03 23:19:45 +01:00
|
|
|
</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>
|
2026-01-30 13:41:32 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 23:19:45 +01:00
|
|
|
function SortableRoundRow({
|
2026-02-02 22:33:55 +01:00
|
|
|
round,
|
|
|
|
|
index,
|
|
|
|
|
totalRounds,
|
2026-02-03 23:19:45 +01:00
|
|
|
isReordering,
|
2026-02-02 22:33:55 +01:00
|
|
|
}: {
|
2026-02-03 23:19:45 +01:00
|
|
|
round: RoundData
|
2026-02-02 22:33:55 +01:00
|
|
|
index: number
|
|
|
|
|
totalRounds: number
|
2026-02-03 23:19:45 +01:00
|
|
|
isReordering: boolean
|
2026-02-02 22:33:55 +01:00
|
|
|
}) {
|
2026-01-30 13:41:32 +01:00
|
|
|
const utils = trpc.useUtils()
|
2026-01-30 19:28:57 +01:00
|
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-02-03 23:19:45 +01:00
|
|
|
const {
|
|
|
|
|
attributes,
|
|
|
|
|
listeners,
|
|
|
|
|
setNodeRef,
|
|
|
|
|
transform,
|
|
|
|
|
transition,
|
|
|
|
|
isDragging,
|
|
|
|
|
} = useSortable({ id: round.id })
|
2026-02-02 22:33:55 +01:00
|
|
|
|
2026-02-03 23:19:45 +01:00
|
|
|
const style = {
|
|
|
|
|
transform: CSS.Transform.toString(transform),
|
|
|
|
|
transition,
|
2026-02-02 22:33:55 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
|
|
|
|
onSuccess: () => {
|
2026-02-03 22:15:22 +01:00
|
|
|
utils.program.list.invalidate({ includeRounds: true })
|
2026-01-30 13:41:32 +01:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 19:28:57 +01:00
|
|
|
const deleteRound = trpc.round.delete.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
toast.success('Round deleted successfully')
|
2026-02-03 22:15:22 +01:00
|
|
|
utils.program.list.invalidate({ includeRounds: true })
|
2026-01-30 19:28:57 +01:00
|
|
|
},
|
|
|
|
|
onError: (error) => {
|
|
|
|
|
toast.error(error.message || 'Failed to delete round')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
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) {
|
2026-02-03 23:19:45 +01:00
|
|
|
return <span className="text-muted-foreground text-sm">Not set</span>
|
2026-01-30 13:41:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const start = new Date(round.votingStartAt)
|
|
|
|
|
const end = new Date(round.votingEndAt)
|
|
|
|
|
|
|
|
|
|
if (isFuture(start)) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="text-sm">
|
2026-02-03 23:19:45 +01:00
|
|
|
Opens {format(start, 'MMM d')}
|
2026-01-30 13:41:32 +01:00
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPast(end)) {
|
|
|
|
|
return (
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
2026-02-03 23:19:45 +01:00
|
|
|
Ended {format(end, 'MMM d')}
|
2026-01-30 13:41:32 +01:00
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span className="text-sm">
|
2026-02-03 23:19:45 +01:00
|
|
|
Until {format(end, 'MMM d')}
|
2026-01-30 13:41:32 +01:00
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
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 "{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.
|
|
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return (
|
2026-02-03 23:19:45 +01:00
|
|
|
<div
|
|
|
|
|
ref={setNodeRef}
|
|
|
|
|
style={style}
|
|
|
|
|
className={cn(
|
2026-02-05 20:31:08 +01:00
|
|
|
'rounded-lg border bg-card transition-all',
|
2026-02-03 23:19:45 +01:00
|
|
|
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
|
|
|
|
isReordering && !isDragging && 'opacity-50'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* Status */}
|
|
|
|
|
<div>{getStatusBadge()}</div>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* Voting window */}
|
|
|
|
|
<div>{getVotingWindow()}</div>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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>
|
2026-02-03 23:19:45 +01:00
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* Actions */}
|
|
|
|
|
<div>
|
|
|
|
|
{actionsMenu}
|
|
|
|
|
</div>
|
2026-02-03 23:19:45 +01:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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}
|
2026-01-30 19:28:57 +01:00
|
|
|
>
|
2026-02-05 20:31:08 +01:00
|
|
|
<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>
|
2026-02-03 23:19:45 +01:00
|
|
|
</div>
|
2026-02-05 20:31:08 +01:00
|
|
|
|
|
|
|
|
{deleteDialog}
|
2026-02-03 23:19:45 +01:00
|
|
|
</div>
|
2026-01-30 13:41:32 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* Desktop skeleton */}
|
|
|
|
|
<div className="hidden lg:block space-y-3">
|
2026-01-30 13:41:32 +01:00
|
|
|
{[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>
|
2026-02-05 20:31:08 +01:00
|
|
|
{/* 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>
|
2026-01-30 13:41:32 +01:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|