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

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 &quot;{round.name}&quot;? 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>
)
}