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

409 lines
15 KiB
TypeScript
Raw Normal View History

'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Save,
Loader2,
ChevronDown,
Play,
Square,
Archive,
} from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
import { FileRequirementsEditor } from '@/components/admin/rounds/config/file-requirements-editor'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const roundStatusConfig: Record<string, { label: string; bgClass: string }> = {
ROUND_DRAFT: { label: 'Draft', bgClass: 'bg-gray-100 text-gray-700' },
ROUND_ACTIVE: { label: 'Active', bgClass: 'bg-emerald-100 text-emerald-700' },
ROUND_CLOSED: { label: 'Closed', bgClass: 'bg-blue-100 text-blue-700' },
ROUND_ARCHIVED: { label: 'Archived', bgClass: 'bg-muted text-muted-foreground' },
}
export default function RoundDetailPage() {
const params = useParams()
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const [confirmAction, setConfirmAction] = useState<string | null>(null)
const utils = trpc.useUtils()
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
// Fetch competition for jury groups (DELIBERATION) and awards
const competitionId = round?.competitionId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId! },
{ enabled: !!competitionId }
)
const juryGroups = competition?.juryGroups?.map((g: any) => ({ id: g.id, name: g.name }))
// Fetch awards linked to this round
const programId = competition?.programId
const { data: awards } = trpc.specialAward.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
)
// Filter awards by this round
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) || []
// Update local config when round data changes
if (round && !hasChanges) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
}
}
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
},
onError: (err) => toast.error(err.message),
})
// Round lifecycle mutations
const activateMutation = trpc.roundEngine.activate.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round activated')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const closeMutation = trpc.roundEngine.close.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round archived')
setConfirmAction(null)
},
onError: (err) => toast.error(err.message),
})
const handleConfigChange = (newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}
const handleSave = () => {
updateMutation.mutate({ id: roundId, configJson: config })
}
const handleLifecycleAction = () => {
if (confirmAction === 'activate') activateMutation.mutate({ roundId })
else if (confirmAction === 'close') closeMutation.mutate({ roundId })
else if (confirmAction === 'archive') archiveMutation.mutate({ roundId })
}
const isLifecyclePending = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!round) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={"/admin/rounds" as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">The requested round does not exist</p>
</div>
</div>
</div>
)
}
const statusCfg = roundStatusConfig[round.status] ?? roundStatusConfig.ROUND_DRAFT
const canActivate = round.status === 'ROUND_DRAFT'
const canClose = round.status === 'ROUND_ACTIVE'
const canArchive = round.status === 'ROUND_CLOSED'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={"/admin/rounds" as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold truncate">{round.name}</h1>
<Badge variant="secondary" className={cn('text-[10px]', roundTypeColors[round.roundType])}>
{round.roundType.replace('_', ' ')}
</Badge>
{/* Status Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors hover:opacity-80',
statusCfg.bgClass,
)}>
{statusCfg.label}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{canActivate && (
<DropdownMenuItem onClick={() => setConfirmAction('activate')}>
<Play className="h-4 w-4 mr-2 text-emerald-600" />
Activate Round
</DropdownMenuItem>
)}
{canClose && (
<DropdownMenuItem onClick={() => setConfirmAction('close')}>
<Square className="h-4 w-4 mr-2 text-blue-600" />
Close Round
</DropdownMenuItem>
)}
{canArchive && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmAction('archive')}>
<Archive className="h-4 w-4 mr-2" />
Archive Round
</DropdownMenuItem>
</>
)}
{!canActivate && !canClose && !canArchive && (
<DropdownMenuItem disabled>No actions available</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="config" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="projects">Projects</TabsTrigger>
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="awards">
Awards
{roundAwards.length > 0 && (
<Badge variant="secondary" className="ml-2">
{roundAwards.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="space-y-4">
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
juryGroups={juryGroups}
/>
</TabsContent>
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={round.competitionId} roundId={roundId} />
</TabsContent>
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={round.competitionId} roundId={roundId} />
</TabsContent>
<TabsContent value="documents" className="space-y-4">
<FileRequirementsEditor roundId={roundId} />
</TabsContent>
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
{roundAwards.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p className="text-sm">No awards linked to this round</p>
<p className="text-xs mt-1">
Create an award and set this round as its source round to see it here
</p>
</div>
) : (
<div className="space-y-3">
{roundAwards.map((award) => {
const eligibleCount = award._count?.eligibilities || 0
const autoTagRules = award.autoTagRulesJson as { rules?: unknown[] } | null
const ruleCount = autoTagRules?.rules?.length || 0
return (
<Link
key={award.id}
href={`/admin/awards/${award.id}` as Route}
className="block"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4 transition-all hover:bg-muted/50 hover:shadow-sm">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{award.name}</h3>
<Badge
variant={
award.eligibilityMode === 'SEPARATE_POOL'
? 'default'
: 'secondary'
}
className="shrink-0"
>
{award.eligibilityMode === 'SEPARATE_POOL'
? 'Separate Pool'
: 'Stay in Main'}
</Badge>
</div>
{award.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{award.description}
</p>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
<div className="text-right">
<div className="font-medium text-foreground">
{ruleCount}
</div>
<div className="text-xs">
{ruleCount === 1 ? 'rule' : 'rules'}
</div>
</div>
<div className="text-right">
<div className="font-medium text-foreground">
{eligibleCount}
</div>
<div className="text-xs">eligible</div>
</div>
</div>
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Lifecycle Confirmation Dialog */}
<Dialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{confirmAction === 'activate' && 'Activate Round'}
{confirmAction === 'close' && 'Close Round'}
{confirmAction === 'archive' && 'Archive Round'}
</DialogTitle>
<DialogDescription>
{confirmAction === 'activate' && 'This will open the round for submissions and evaluations. Projects will be able to enter this round.'}
{confirmAction === 'close' && 'This will close the round. No more submissions or evaluations will be accepted.'}
{confirmAction === 'archive' && 'This will archive the round. It will no longer appear in active views.'}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmAction(null)}>Cancel</Button>
<Button onClick={handleLifecycleAction} disabled={isLifecyclePending}>
{isLifecyclePending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}