Round detail overhaul, file requirements, project management, audit log fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents) - Add jury group assignment selector in round stats bar - Add FileRequirementsEditor component replacing SubmissionWindowManager - Add FilteringDashboard component for AI-powered project screening - Add project removal from rounds (single + bulk) with cascading to subsequent rounds - Add project add/remove UI in ProjectStatesTable with confirmation dialogs - Fix logAudit inside $transaction pattern across all 12 router files (PostgreSQL aborted-transaction state caused silent operation failures) - Fix special awards creation, deletion, status update, and winner assignment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -362,7 +362,6 @@ export default function CompetitionDetailPage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,44 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { ArrowLeft, Save, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Square,
|
||||
Archive,
|
||||
Layers,
|
||||
Users,
|
||||
CalendarDays,
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Shield,
|
||||
UserPlus,
|
||||
} 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/round/file-requirements-editor'
|
||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||
|
||||
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',
|
||||
// -- Status config --
|
||||
const roundStatusConfig = {
|
||||
ROUND_DRAFT: {
|
||||
label: 'Draft',
|
||||
bgClass: 'bg-gray-100 text-gray-700',
|
||||
dotClass: 'bg-gray-500',
|
||||
description: 'Not yet active. Configure before launching.',
|
||||
},
|
||||
ROUND_ACTIVE: {
|
||||
label: 'Active',
|
||||
bgClass: 'bg-emerald-100 text-emerald-700',
|
||||
dotClass: 'bg-emerald-500 animate-pulse',
|
||||
description: 'Round is live. Projects can be processed.',
|
||||
},
|
||||
ROUND_CLOSED: {
|
||||
label: 'Closed',
|
||||
bgClass: 'bg-blue-100 text-blue-700',
|
||||
dotClass: 'bg-blue-500',
|
||||
description: 'No longer accepting changes. Results are final.',
|
||||
},
|
||||
ROUND_ARCHIVED: {
|
||||
label: 'Archived',
|
||||
bgClass: 'bg-muted text-muted-foreground',
|
||||
dotClass: 'bg-muted-foreground',
|
||||
description: 'Historical record only.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const roundTypeConfig: Record<string, { label: string; color: string; description: string }> = {
|
||||
INTAKE: { label: 'Intake', color: 'bg-gray-100 text-gray-700', description: 'Collecting applications' },
|
||||
FILTERING: { label: 'Filtering', color: 'bg-amber-100 text-amber-700', description: 'AI + manual screening' },
|
||||
EVALUATION: { label: 'Evaluation', color: 'bg-blue-100 text-blue-700', description: 'Jury evaluation & scoring' },
|
||||
SUBMISSION: { label: 'Submission', color: 'bg-purple-100 text-purple-700', description: 'Document submission' },
|
||||
MENTORING: { label: 'Mentoring', color: 'bg-teal-100 text-teal-700', description: 'Mentor-guided development' },
|
||||
LIVE_FINAL: { label: 'Live Final', color: 'bg-red-100 text-red-700', description: 'Live presentations & voting' },
|
||||
DELIBERATION: { label: 'Deliberation', color: 'bg-indigo-100 text-indigo-700', description: 'Final jury deliberation' },
|
||||
}
|
||||
|
||||
export default function RoundDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const competitionId = params.competitionId as string
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
|
||||
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery({ roundId })
|
||||
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||
{ competitionId },
|
||||
{ enabled: !!competitionId },
|
||||
)
|
||||
|
||||
// Update local config when round data changes
|
||||
// Sync config from server when not dirty
|
||||
if (round && !hasChanges) {
|
||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
@@ -46,6 +127,7 @@ export default function RoundDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Mutations --
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
@@ -55,30 +137,79 @@ export default function RoundDetailPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const activateMutation = trpc.roundEngine.activate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round activated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeMutation = trpc.roundEngine.close.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round closed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const archiveMutation = trpc.roundEngine.archive.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round archived')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const assignJuryMutation = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Jury group updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const isTransitioning = activateMutation.isPending || closeMutation.isPending || archiveMutation.isPending
|
||||
|
||||
const handleConfigChange = (newConfig: Record<string, unknown>) => {
|
||||
setConfig(newConfig)
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
id: roundId,
|
||||
configJson: config,
|
||||
})
|
||||
updateMutation.mutate({ id: roundId, configJson: config })
|
||||
}
|
||||
|
||||
// -- Computed --
|
||||
const projectCount = round?._count?.projectRoundStates ?? 0
|
||||
const stateCounts = projectStates?.reduce((acc: Record<string, number>, ps: any) => {
|
||||
acc[ps.state] = (acc[ps.state] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>) ?? {}
|
||||
const juryGroup = round?.juryGroup
|
||||
const juryMemberCount = juryGroup?.members?.length ?? 0
|
||||
|
||||
// Determine available tabs based on round type
|
||||
const isFiltering = round?.roundType === 'FILTERING'
|
||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
||||
const hasSubmissionWindows = round?.roundType === 'SUBMISSION' || round?.roundType === 'EVALUATION' || round?.roundType === 'INTAKE'
|
||||
|
||||
// Loading
|
||||
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 className="space-y-2">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,73 +219,550 @@ export default function RoundDetailPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/competitions/${competitionId}` as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back">
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">This round does not exist.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = round.status as keyof typeof roundStatusConfig
|
||||
const statusCfg = roundStatusConfig[status] || roundStatusConfig.ROUND_DRAFT
|
||||
const typeCfg = roundTypeConfig[round.roundType] || roundTypeConfig.INTAKE
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* ===== HEADER ===== */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={`/admin/competitions/${competitionId}` as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition">
|
||||
<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={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
<h1 className="text-xl font-bold tracking-tight truncate">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-xs shrink-0', typeCfg.color)}>
|
||||
{typeCfg.label}
|
||||
</Badge>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-full transition-colors shrink-0',
|
||||
statusCfg.bgClass,
|
||||
'hover:opacity-80',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', statusCfg.dotClass)} />
|
||||
{statusCfg.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{status === 'ROUND_DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => activateMutation.mutate({ roundId })}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{status === 'ROUND_ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => closeMutation.mutate({ roundId })}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-2 text-blue-600" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{status === 'ROUND_CLOSED' && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => activateMutation.mutate({ roundId })}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2 text-emerald-600" />
|
||||
Reactivate Round
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => archiveMutation.mutate({ roundId })}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Updating...
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{typeCfg.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
<Save className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
Save Changes
|
||||
Save Config
|
||||
</Button>
|
||||
)}
|
||||
{(isEvaluation || isFiltering) && (
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ClipboardList className="h-4 w-4 mr-1.5" />
|
||||
Assignments
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Layers className="h-4 w-4 mr-1.5" />
|
||||
Project Pool
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="config" className="space-y-4">
|
||||
{/* ===== STATS BAR ===== */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Projects</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{projectCount}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{Object.entries(stateCounts).map(([state, count]) => (
|
||||
<span key={state} className="text-[10px] text-muted-foreground">
|
||||
{String(count)} {state.toLowerCase().replace('_', ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2 mb-1" data-jury-select>
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Jury</span>
|
||||
</div>
|
||||
{juryGroups && juryGroups.length > 0 ? (
|
||||
<Select
|
||||
value={round.juryGroupId ?? '__none__'}
|
||||
onValueChange={(value) => {
|
||||
assignJuryMutation.mutate({
|
||||
id: roundId,
|
||||
juryGroupId: value === '__none__' ? null : value,
|
||||
})
|
||||
}}
|
||||
disabled={assignJuryMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectValue placeholder="Select jury group..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
||||
{juryGroups.map((jg: any) => (
|
||||
<SelectItem key={jg.id} value={jg.id}>
|
||||
{jg.name} ({jg._count?.members ?? 0} members)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : juryGroup ? (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1">{juryMemberCount}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1 text-muted-foreground">—</p>
|
||||
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Window</span>
|
||||
</div>
|
||||
{round.windowOpenAt || round.windowCloseAt ? (
|
||||
<>
|
||||
<p className="text-sm font-bold mt-1">
|
||||
{round.windowOpenAt
|
||||
? new Date(round.windowOpenAt).toLocaleDateString()
|
||||
: 'No start'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.windowCloseAt
|
||||
? `Closes ${new Date(round.windowCloseAt).toLocaleDateString()}`
|
||||
: 'No deadline'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1 text-muted-foreground">—</p>
|
||||
<p className="text-xs text-muted-foreground">No dates set</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Advancement</span>
|
||||
</div>
|
||||
{round.advancementRules && round.advancementRules.length > 0 ? (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1">{round.advancementRules.length}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.advancementRules.map((r: any) => r.ruleType.replace('_', ' ').toLowerCase()).join(', ')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-2xl font-bold mt-1 text-muted-foreground">—</p>
|
||||
<p className="text-xs text-muted-foreground">Admin selection</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ===== TABS ===== */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} 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="overview">
|
||||
<Zap className="h-3.5 w-3.5 mr-1.5" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="projects">
|
||||
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||
Projects
|
||||
</TabsTrigger>
|
||||
{isFiltering && (
|
||||
<TabsTrigger value="filtering">
|
||||
<Shield className="h-3.5 w-3.5 mr-1.5" />
|
||||
Filtering
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isEvaluation && (
|
||||
<TabsTrigger value="assignments">
|
||||
<ClipboardList className="h-3.5 w-3.5 mr-1.5" />
|
||||
Assignments
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="config">
|
||||
<Settings className="h-3.5 w-3.5 mr-1.5" />
|
||||
Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="windows">
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Config Tab */}
|
||||
{/* ===== OVERVIEW TAB ===== */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Quick Actions</CardTitle>
|
||||
<CardDescription>Common operations for this round</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Status transitions */}
|
||||
{status === 'ROUND_DRAFT' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left">
|
||||
<Play className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Activate Round</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Start this round and allow project processing
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Activate this round?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The round will go live. Projects can be processed and jury members will be able to see their assignments.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => activateMutation.mutate({ roundId })}>
|
||||
Activate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{status === 'ROUND_ACTIVE' && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left">
|
||||
<Square className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Close Round</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Stop accepting changes and finalize results
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Close this round?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
No further changes will be accepted. You can reactivate later if needed.
|
||||
{projectCount > 0 && (
|
||||
<span className="block mt-2">
|
||||
{projectCount} projects are currently in this round.
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => closeMutation.mutate({ roundId })}>
|
||||
Close Round
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Assign projects */}
|
||||
<Link href={'/admin/projects/pool' as Route}>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
||||
<Layers className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Assign Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add projects from the pool to this round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* Filtering specific */}
|
||||
{isFiltering && (
|
||||
<button
|
||||
onClick={() => setActiveTab('filtering')}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<Shield className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Run AI Filtering</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Screen projects with AI and manual review
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Jury assignment for evaluation/filtering */}
|
||||
{(isEvaluation || isFiltering) && !juryGroup && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.querySelector('[data-jury-select]')
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left border-amber-200 bg-amber-50/50"
|
||||
>
|
||||
<UserPlus className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Assign Jury Group</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
No jury group assigned. Select one in the Jury card above.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Evaluation specific */}
|
||||
{isEvaluation && (
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<button className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left w-full">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Manage Assignments</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Generate and review jury-project assignments
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* View projects */}
|
||||
<button
|
||||
onClick={() => setActiveTab('projects')}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<BarChart3 className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Manage Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
View, filter, and transition project states
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Round info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Round Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<Badge variant="secondary" className={cn('text-xs', typeCfg.color)}>{typeCfg.label}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className="font-medium">{statusCfg.label}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sort Order</span>
|
||||
<span className="font-medium font-mono">{round.sortOrder}</span>
|
||||
</div>
|
||||
{round.purposeKey && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Purpose</span>
|
||||
<span className="font-medium">{round.purposeKey}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Jury Group</span>
|
||||
<span className="font-medium">
|
||||
{juryGroup ? juryGroup.name : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Opens</span>
|
||||
<span className="font-medium">
|
||||
{round.windowOpenAt ? new Date(round.windowOpenAt).toLocaleString() : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Closes</span>
|
||||
<span className="font-medium">
|
||||
{round.windowCloseAt ? new Date(round.windowCloseAt).toLocaleString() : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Project Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projectCount === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No projects assigned yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].map((state) => {
|
||||
const count = stateCounts[state] || 0
|
||||
if (count === 0) return null
|
||||
const pct = ((count / projectCount) * 100).toFixed(0)
|
||||
const colors: Record<string, string> = {
|
||||
PENDING: 'bg-gray-400',
|
||||
IN_PROGRESS: 'bg-blue-500',
|
||||
PASSED: 'bg-green-500',
|
||||
REJECTED: 'bg-red-500',
|
||||
COMPLETED: 'bg-emerald-500',
|
||||
WITHDRAWN: 'bg-orange-400',
|
||||
}
|
||||
return (
|
||||
<div key={state}>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-muted-foreground capitalize">{state.toLowerCase().replace('_', ' ')}</span>
|
||||
<span className="font-medium">{count} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', colors[state])}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== PROJECTS TAB ===== */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== FILTERING TAB ===== */}
|
||||
{isFiltering && (
|
||||
<TabsContent value="filtering" className="space-y-4">
|
||||
<FilteringDashboard competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ===== ASSIGNMENTS TAB (Evaluation rounds) ===== */}
|
||||
{isEvaluation && (
|
||||
<TabsContent value="assignments" className="space-y-4">
|
||||
<RoundAssignmentsOverview competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ===== CONFIG TAB ===== */}
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
<RoundConfigForm
|
||||
roundType={round.roundType}
|
||||
@@ -163,16 +771,129 @@ export default function RoundDetailPage() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Projects Tab */}
|
||||
<TabsContent value="projects" className="space-y-4">
|
||||
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Submission Windows Tab */}
|
||||
{/* ===== DOCUMENTS TAB ===== */}
|
||||
<TabsContent value="windows" className="space-y-4">
|
||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
||||
<FileRequirementsEditor
|
||||
roundId={roundId}
|
||||
windowOpenAt={round.windowOpenAt}
|
||||
windowCloseAt={round.windowCloseAt}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Inline sub-component for evaluation round assignments =====
|
||||
|
||||
function RoundAssignmentsOverview({ competitionId, roundId }: { competitionId: string; roundId: string }) {
|
||||
const { data: coverage, isLoading: coverageLoading } = trpc.roundAssignment.coverageReport.useQuery({
|
||||
roundId,
|
||||
requiredReviews: 3,
|
||||
})
|
||||
|
||||
const { data: unassigned, isLoading: unassignedLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId, requiredReviews: 3 },
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Coverage stats */}
|
||||
{coverageLoading ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-28" />)}
|
||||
</div>
|
||||
) : coverage ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Fully Assigned</CardTitle>
|
||||
<Users className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{coverage.fullyAssigned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {coverage.totalProjects || 0} projects ({coverage.totalProjects ? ((coverage.fullyAssigned / coverage.totalProjects) * 100).toFixed(0) : 0}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Reviews/Project</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{coverage.avgReviewsPerProject?.toFixed(1) || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">Target: 3 per project</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Unassigned</CardTitle>
|
||||
<Layers className="h-4 w-4 text-amber-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-700">{coverage.unassigned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Need more assignments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Unassigned queue */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
||||
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
|
||||
</div>
|
||||
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
|
||||
<Button size="sm">
|
||||
<ClipboardList className="h-4 w-4 mr-1.5" />
|
||||
Full Assignment Dashboard
|
||||
<ExternalLink className="h-3 w-3 ml-1.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{unassignedLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : unassigned && unassigned.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{unassigned.map((project: any) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex justify-between items-center p-3 border rounded-md hover:bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{project.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.competitionCategory || 'No category'}
|
||||
{project.teamName && ` · ${project.teamName}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs shrink-0 ml-3',
|
||||
(project.assignmentCount || 0) === 0
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
)}>
|
||||
{project.assignmentCount || 0} / 3
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
All projects have sufficient assignments
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user