406 lines
12 KiB
TypeScript
406 lines
12 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 {
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
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,
|
|
} from 'lucide-react'
|
|
import { format, isPast, isFuture } from 'date-fns'
|
|
|
|
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) => (
|
|
<Card key={program.id}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">{program.name}</CardTitle>
|
|
<CardDescription>
|
|
{program.year} - {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>
|
|
{program.rounds && program.rounds.length > 0 ? (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Round</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Voting Window</TableHead>
|
|
<TableHead>Projects</TableHead>
|
|
<TableHead>Assignments</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{program.rounds.map((round) => (
|
|
<RoundRow key={round.id} round={round} />
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<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>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RoundRow({ round }: { round: any }) {
|
|
const utils = trpc.useUtils()
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
|
|
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
|
onSuccess: () => {
|
|
utils.program.list.invalidate()
|
|
},
|
|
})
|
|
|
|
const deleteRound = trpc.round.delete.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Round deleted successfully')
|
|
utils.program.list.invalidate()
|
|
},
|
|
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">Not set</span>
|
|
}
|
|
|
|
const start = new Date(round.votingStartAt)
|
|
const end = new Date(round.votingEndAt)
|
|
const now = new Date()
|
|
|
|
if (isFuture(start)) {
|
|
return (
|
|
<span className="text-sm">
|
|
Opens {format(start, 'MMM d, yyyy')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
if (isPast(end)) {
|
|
return (
|
|
<span className="text-sm text-muted-foreground">
|
|
Ended {format(end, 'MMM d, yyyy')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className="text-sm">
|
|
Until {format(end, 'MMM d, yyyy')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<TableRow>
|
|
<TableCell>
|
|
<Link
|
|
href={`/admin/rounds/${round.id}`}
|
|
className="font-medium hover:underline"
|
|
>
|
|
{round.name}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell>{getStatusBadge()}</TableCell>
|
|
<TableCell>{getVotingWindow()}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
{round._count?.projects || 0}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1">
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
|
{round._count?.assignments || 0}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" 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 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>
|
|
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete "{round.name}"? This will
|
|
permanently delete all {round._count?.projects || 0} projects,{' '}
|
|
{round._count?.assignments || 0} assignments, and all evaluations
|
|
in this round. 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>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}
|
|
|
|
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>
|
|
<div className="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>
|
|
</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>
|
|
)
|
|
}
|