Files
MOPC-Portal/src/app/(admin)/admin/awards/[id]/page.tsx
Matt 84d90e1978
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
fix: soften award notification email tone from "selected" to "under consideration"
The email was implying projects had won the award. Updated banner, subject,
and body copy to clarify they are being considered, not awarded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:04:28 +01:00

1632 lines
67 KiB
TypeScript

'use client'
import { use, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Pagination } from '@/components/shared/pagination'
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
import { toast } from 'sonner'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Collapsible,
CollapsibleContent,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
Trophy,
Users,
CheckCircle2,
ListChecks,
BarChart3,
Loader2,
Bot,
Crown,
UserPlus,
X,
Play,
Lock,
Pencil,
Trash2,
Plus,
Search,
Vote,
ChevronDown,
AlertCircle,
Layers,
Info,
Mail,
GripVertical,
ArrowRight,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DRAFT: 'secondary',
NOMINATIONS_OPEN: 'default',
VOTING_OPEN: 'default',
CLOSED: 'outline',
ARCHIVED: 'secondary',
}
// Status workflow steps for the step indicator
const WORKFLOW_STEPS = [
{ key: 'DRAFT', label: 'Draft' },
{ key: 'NOMINATIONS_OPEN', label: 'Nominations' },
{ key: 'VOTING_OPEN', label: 'Voting' },
{ key: 'CLOSED', label: 'Closed' },
] as const
function getStepIndex(status: string): number {
const idx = WORKFLOW_STEPS.findIndex((s) => s.key === status)
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
}
const ROUND_TYPE_COLORS: Record<string, string> = {
EVALUATION: 'bg-violet-100 text-violet-700',
FILTERING: 'bg-amber-100 text-amber-700',
SUBMISSION: 'bg-blue-100 text-blue-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-rose-100 text-rose-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
const ROUND_STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
function SortableRoundCard({
round,
index,
isFirst,
onDelete,
isDeleting,
}: {
round: any
index: number
isFirst: boolean
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
return (
<Card
ref={setNodeRef}
style={style}
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
>
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start gap-2.5">
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
{round.name}
</Link>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
{round.roundType.replace('_', ' ')}
</Badge>
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
{statusLabel}
</Badge>
{isFirst && (
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
Entry point
</Badge>
)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{assignmentCount > 0 && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ListChecks className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{round.status === 'ROUND_DRAFT' && (
<div className="flex justify-end pt-1">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{round.name}&quot;. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(round.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
)
}
function RoundsDndGrid({
rounds,
awardId,
onReorder,
onDelete,
isDeleting,
}: {
rounds: any[]
awardId: string
onReorder: (roundIds: string[]) => void
onDelete: (roundId: string) => void
isDeleting: boolean
}) {
const [items, setItems] = useState(rounds.map((r: any) => r.id))
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// Sync if server data changes
useEffect(() => {
setItems(rounds.map((r: any) => r.id))
}, [rounds])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.indexOf(active.id as string)
const newIndex = items.indexOf(over.id as string)
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
onReorder(newItems)
}
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((id, index) => {
const round = roundMap.get(id)
if (!round) return null
return (
<SortableRoundCard
key={id}
round={round}
index={index}
isFirst={index === 0}
onDelete={onDelete}
isDeleting={isDeleting}
/>
)
})}
</div>
</SortableContext>
</DndContext>
)
}
function ConfidenceBadge({ confidence }: { confidence: number }) {
if (confidence > 0.8) {
return (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
}
if (confidence >= 0.5) {
return (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-400 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
}
return (
<Badge variant="outline" className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-400 text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)
}
export default function AwardDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
// State declarations (before queries that depend on them)
const [isPollingJob, setIsPollingJob] = useState(false)
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true)
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
const eligibilityPerPage = 25
// Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
page: eligibilityPage,
perPage: eligibilityPerPage,
}, {
enabled: activeTab === 'eligibility',
})
const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId }, {
enabled: activeTab === 'jurors',
})
const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results',
})
const { data: awardRounds, refetch: refetchRounds } =
trpc.specialAward.listRounds.useQuery({ awardId }, {
enabled: activeTab === 'rounds',
})
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
{ role: 'JURY_MEMBER', page: 1, perPage: 100 },
{ enabled: activeTab === 'jurors' }
)
const { data: allProjects } = trpc.project.list.useQuery(
{ programId: award?.programId ?? '', perPage: 200 },
{ enabled: !!award?.programId && addProjectDialogOpen }
)
// Eligibility job polling
const { data: jobStatus, refetch: refetchJobStatus } =
trpc.specialAward.getEligibilityJobStatus.useQuery(
{ awardId },
{ enabled: isPollingJob }
)
useEffect(() => {
if (!isPollingJob) return
pollingIntervalRef.current = setInterval(() => {
refetchJobStatus()
}, 2000)
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current)
pollingIntervalRef.current = null
}
}
}, [isPollingJob, refetchJobStatus])
// React to job status changes
useEffect(() => {
if (!jobStatus || !isPollingJob) return
if (jobStatus.eligibilityJobStatus === 'COMPLETED') {
setIsPollingJob(false)
toast.success('Eligibility processing completed')
refetchEligibility()
refetch()
} else if (jobStatus.eligibilityJobStatus === 'FAILED') {
setIsPollingJob(false)
toast.error(jobStatus.eligibilityJobError || 'Eligibility processing failed')
}
}, [jobStatus, isPollingJob, refetchEligibility, refetch])
// Check on mount if there's an ongoing job
useEffect(() => {
if (award?.eligibilityJobStatus === 'PROCESSING' || award?.eligibilityJobStatus === 'PENDING') {
setIsPollingJob(true)
}
}, [award?.eligibilityJobStatus])
const utils = trpc.useUtils()
const invalidateAward = () => {
utils.specialAward.get.invalidate({ id: awardId })
utils.specialAward.listEligible.invalidate({ awardId })
utils.specialAward.listJurors.invalidate({ awardId })
utils.specialAward.getVoteResults.invalidate({ awardId })
}
const updateStatus = trpc.specialAward.updateStatus.useMutation({
onSuccess: invalidateAward,
})
const runEligibility = trpc.specialAward.runEligibility.useMutation({
onSuccess: invalidateAward,
})
const setEligibility = trpc.specialAward.setEligibility.useMutation({
onSuccess: () => utils.specialAward.listEligible.invalidate({ awardId }),
})
const addJuror = trpc.specialAward.addJuror.useMutation({
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
})
const removeJuror = trpc.specialAward.removeJuror.useMutation({
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
})
const setWinner = trpc.specialAward.setWinner.useMutation({
onSuccess: invalidateAward,
})
const deleteAward = trpc.specialAward.delete.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(),
})
const createRound = trpc.specialAward.createRound.useMutation({
onSuccess: () => {
refetchRounds()
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: 'EVALUATION' })
toast.success('Round created')
},
onError: (err) => toast.error(err.message),
})
const deleteRound = trpc.specialAward.deleteRound.useMutation({
onSuccess: () => {
refetchRounds()
toast.success('Round deleted')
},
onError: (err) => toast.error(err.message),
})
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
onSuccess: () => refetchRounds(),
onError: (err) => toast.error(err.message),
})
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
refetchRounds()
refetch()
},
onError: (err) => toast.error(err.message),
})
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
{ awardId, customMessage: notifyCustomMessage },
{ enabled: notifyDialogOpen }
)
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
onSuccess: (result) => {
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
setNotifyDialogOpen(false)
setNotifyCustomMessage(undefined)
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
) => {
try {
await updateStatus.mutateAsync({ id: awardId, status })
toast.success(`Status updated to ${status.replace('_', ' ')}`)
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to update status'
)
}
}
const handleRunEligibility = async () => {
try {
await runEligibility.mutateAsync({ awardId, includeSubmitted })
toast.success('Eligibility processing started')
setIsPollingJob(true)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to start eligibility'
)
}
}
const handleToggleEligibility = async (
projectId: string,
eligible: boolean
) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible })
refetchEligibility()
} catch {
toast.error('Failed to update eligibility')
}
}
const handleAddJuror = async () => {
if (!selectedJurorId) return
try {
await addJuror.mutateAsync({ awardId, userId: selectedJurorId })
toast.success('Juror added')
setSelectedJurorId('')
refetchJurors()
} catch {
toast.error('Failed to add juror')
}
}
const handleRemoveJuror = async (userId: string) => {
try {
await removeJuror.mutateAsync({ awardId, userId })
refetchJurors()
} catch {
toast.error('Failed to remove juror')
}
}
const handleSetWinner = async (projectId: string) => {
try {
await setWinner.mutateAsync({
awardId,
projectId,
overridden: true,
})
toast.success('Winner set')
refetch()
} catch {
toast.error('Failed to set winner')
}
}
const handleDeleteAward = async () => {
try {
await deleteAward.mutateAsync({ id: awardId })
toast.success('Award deleted')
router.push('/admin/awards')
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to delete award'
)
}
}
const handleAddProjectToEligibility = async (projectId: string) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible: true })
toast.success('Project added to eligibility list')
refetchEligibility()
refetch()
} catch {
toast.error('Failed to add project')
}
}
const handleRemoveFromEligibility = async (projectId: string) => {
try {
await setEligibility.mutateAsync({ awardId, projectId, eligible: false })
toast.success('Project removed from eligibility')
refetchEligibility()
refetch()
} catch {
toast.error('Failed to remove project')
}
}
// Get projects that aren't already in the eligibility list
const eligibleProjectIds = new Set(
eligibilityData?.eligibilities.map((e) => e.projectId) || []
)
const availableProjects = allProjects?.projects.filter(
(p) => !eligibleProjectIds.has(p.id)
) || []
const filteredAvailableProjects = availableProjects.filter(
(p) =>
p.title.toLowerCase().includes(projectSearchQuery.toLowerCase()) ||
p.teamName?.toLowerCase().includes(projectSearchQuery.toLowerCase())
)
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-40 w-full" />
</div>
)
}
if (!award) return null
const jurorUserIds = new Set(jurors?.map((j) => j.userId) || [])
const availableUsers =
allUsers?.users.filter((u) => !jurorUserIds.has(u.id)) || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/awards">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Awards
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace(/_/g, ' ')}
</Badge>
<span className="text-muted-foreground">
{award.program.year} Edition
</span>
{award.votingStartAt && (
<span className="text-xs text-muted-foreground">
Voting: {new Date(award.votingStartAt).toLocaleDateString()} - {award.votingEndAt ? new Date(award.votingEndAt).toLocaleDateString() : 'No end date'}
</span>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/awards/${awardId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{award.status === 'DRAFT' && (
<Button
variant="outline"
onClick={() => handleStatusChange('NOMINATIONS_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Nominations
</Button>
)}
{award.status === 'NOMINATIONS_OPEN' && (
<>
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
<Mail className="mr-2 h-4 w-4" />
Notify Pool ({award.eligibleCount})
</Button>
<EmailPreviewDialog
open={notifyDialogOpen}
onOpenChange={setNotifyDialogOpen}
title="Notify Eligible Projects"
description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
recipientCount={notifyPreview.data?.recipientCount ?? 0}
previewHtml={notifyPreview.data?.html}
isPreviewLoading={notifyPreview.isLoading}
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
isSending={notifyEligible.isPending}
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
/>
{award.eligibilityMode === 'SEPARATE_POOL' ? (
<Button
onClick={() => assignToFirstRound.mutate({ awardId })}
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
>
{assignToFirstRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
) : (
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
)}
</Button>
) : (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
)}
</>
)}
{award.status === 'VOTING_OPEN' && (
<Button
variant="outline"
onClick={() => handleStatusChange('CLOSED')}
disabled={updateStatus.isPending}
>
<Lock className="mr-2 h-4 w-4" />
Close Voting
</Button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Award?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{award.name}&quot; and all associated
eligibility data, juror assignments, and votes. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAward}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAward.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete Award
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Description */}
{award.description && (
<p className="text-muted-foreground">{award.description}</p>
)}
{/* Status Workflow Step Indicator */}
<div className="relative">
<div className="flex items-center justify-between">
{WORKFLOW_STEPS.map((step, i) => {
const currentIdx = getStepIndex(award.status)
const isComplete = i < currentIdx
const isCurrent = i === currentIdx
return (
<div key={step.key} className="flex flex-1 items-center">
<div className="flex flex-col items-center gap-1.5 relative z-10">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
isCurrent
? 'bg-brand-blue text-white ring-2 ring-brand-blue/20 ring-offset-2 ring-offset-background'
: isComplete
? 'bg-brand-blue/90 text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{isComplete ? (
<CheckCircle2 className="h-4 w-4" />
) : (
i + 1
)}
</div>
<span
className={`text-xs font-medium whitespace-nowrap ${
isCurrent ? 'text-foreground' : isComplete ? 'text-muted-foreground' : 'text-muted-foreground/60'
}`}
>
{step.label}
</span>
</div>
{i < WORKFLOW_STEPS.length - 1 && (
<div className="flex-1 mx-2 mt-[-18px]">
<div
className={`h-0.5 w-full rounded-full transition-colors ${
i < currentIdx ? 'bg-brand-blue/70' : 'bg-muted'
}`}
/>
</div>
)}
</div>
)
})}
</div>
</div>
{/* Stats Cards */}
<AnimatedCard index={0}>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Eligible</p>
<p className="text-2xl font-bold tabular-nums">{award.eligibleCount}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/40">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Jurors</p>
<p className="text-2xl font-bold tabular-nums">{award._count.jurors}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-950/40">
<Users className="h-5 w-5 text-violet-600 dark:text-violet-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Votes</p>
<p className="text-2xl font-bold tabular-nums">{award._count.votes}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-950/40">
<Vote className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
</div>
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Tabs */}
<AnimatedCard index={1}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="eligibility">
<CheckCircle2 className="mr-2 h-4 w-4" />
Eligibility ({award.eligibleCount})
</TabsTrigger>
<TabsTrigger value="jurors">
<Users className="mr-2 h-4 w-4" />
Jurors ({award._count.jurors})
</TabsTrigger>
<TabsTrigger value="rounds">
<Layers className="mr-2 h-4 w-4" />
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
</TabsTrigger>
<TabsTrigger value="results">
<BarChart3 className="mr-2 h-4 w-4" />
Results
</TabsTrigger>
</TabsList>
{/* Eligibility Tab */}
<TabsContent value="eligibility" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<p className="text-sm text-muted-foreground">
{award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
eligible
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="include-submitted"
checked={includeSubmitted}
onCheckedChange={setIncludeSubmitted}
/>
<Label htmlFor="include-submitted" className="text-sm whitespace-nowrap">
Include submitted
</Label>
</div>
{award.useAiEligibility ? (
<Button
onClick={handleRunEligibility}
disabled={runEligibility.isPending || isPollingJob}
>
{runEligibility.isPending || isPollingJob ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ListChecks className="mr-2 h-4 w-4" />
)}
{isPollingJob ? 'Processing...' : 'Run AI Eligibility'}
</Button>
) : (
<Button
onClick={handleRunEligibility}
disabled={runEligibility.isPending || isPollingJob}
variant="outline"
>
{runEligibility.isPending || isPollingJob ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{isPollingJob ? 'Processing...' : 'Load All Projects'}
</Button>
)}
<Dialog open={addProjectDialogOpen} onOpenChange={setAddProjectDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Add Project to Eligibility List</DialogTitle>
<DialogDescription>
Manually add a project that wasn&apos;t included by AI or rule-based filtering
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[400px] overflow-y-auto rounded-md border">
{filteredAvailableProjects.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAvailableProjects.slice(0, 50).map((project) => (
<TableRow key={project.id}>
<TableCell>
<div>
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</TableCell>
<TableCell>
{project.competitionCategory ? (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell className="text-sm">{project.country || '-'}</TableCell>
<TableCell className="text-right">
<Button
size="sm"
onClick={() => {
handleAddProjectToEligibility(project.id)
}}
disabled={setEligibility.isPending}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
{projectSearchQuery
? 'No projects match your search'
: 'All projects are already in the eligibility list'}
</p>
</div>
)}
</div>
{filteredAvailableProjects.length > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing first 50 of {filteredAvailableProjects.length} projects. Use search to filter.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddProjectDialogOpen(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Eligibility job progress */}
{isPollingJob && jobStatus && (
<Card>
<CardContent className="py-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{jobStatus.eligibilityJobStatus === 'PENDING'
? 'Preparing...'
: `Processing... ${jobStatus.eligibilityJobDone ?? 0} of ${jobStatus.eligibilityJobTotal ?? '?'} projects`}
</span>
{jobStatus.eligibilityJobTotal && jobStatus.eligibilityJobTotal > 0 && (
<span className="text-muted-foreground">
{Math.round(
((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
)}%
</span>
)}
</div>
<Progress
value={
jobStatus.eligibilityJobTotal && jobStatus.eligibilityJobTotal > 0
? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
: 0
}
gradient
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* Failed job notice */}
{!isPollingJob && award.eligibilityJobStatus === 'FAILED' && (
<Card className="border-destructive/50">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-destructive">
<X className="h-4 w-4" />
<span className="text-sm font-medium">
Last eligibility run failed: {award.eligibilityJobError || 'Unknown error'}
</span>
</div>
<Button size="sm" variant="outline" onClick={handleRunEligibility} disabled={runEligibility.isPending}>
Retry
</Button>
</div>
</CardContent>
</Card>
)}
{!award.useAiEligibility && (
<p className="text-sm text-muted-foreground italic">
AI eligibility is off for this award. Projects are loaded for manual selection.
</p>
)}
{eligibilityData && eligibilityData.eligibilities.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead>Method</TableHead>
{award.useAiEligibility && <TableHead>AI Confidence</TableHead>}
<TableHead>Eligible</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eligibilityData.eligibilities.map((e) => {
const aiReasoning = e.aiReasoningJson as { confidence?: number; reasoning?: string } | null
const hasReasoning = !!aiReasoning?.reasoning
const isExpanded = expandedRows.has(e.id)
return (
<Collapsible key={e.id} open={isExpanded} onOpenChange={(open) => {
setExpandedRows((prev) => {
const next = new Set(prev)
if (open) next.add(e.id)
else next.delete(e.id)
return next
})
}} asChild>
<>
<TableRow
className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer hover:bg-muted/50' : ''}`}
onClick={() => {
if (!hasReasoning) return
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(e.id)) next.delete(e.id)
else next.add(e.id)
return next
})
}}
>
<TableCell>
<div className="flex items-center gap-2">
{hasReasoning && (
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 flex-shrink-0 ${isExpanded ? 'rotate-180' : ''}`} />
)}
<div>
<p className="font-medium">{e.project.title}</p>
<p className="text-sm text-muted-foreground">
{e.project.teamName}
</p>
</div>
</div>
</TableCell>
<TableCell>
{e.project.competitionCategory ? (
<Badge variant="outline">
{e.project.competitionCategory.replace('_', ' ')}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>{e.project.country || '-'}</TableCell>
<TableCell>
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
</Badge>
</TableCell>
{award.useAiEligibility && (
<TableCell>
{aiReasoning?.confidence != null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<ConfidenceBadge confidence={aiReasoning.confidence} />
</TooltipTrigger>
<TooltipContent>
AI confidence: {Math.round(aiReasoning.confidence * 100)}%
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
)}
<TableCell onClick={(ev) => ev.stopPropagation()}>
<Switch
checked={e.eligible}
onCheckedChange={(checked) =>
handleToggleEligibility(e.projectId, checked)
}
/>
</TableCell>
<TableCell className="text-right" onClick={(ev) => ev.stopPropagation()}>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFromEligibility(e.projectId)}
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
{hasReasoning && (
<CollapsibleContent asChild>
<tr>
<td colSpan={award.useAiEligibility ? 7 : 6} className="p-0">
<div className="border-t bg-muted/30 px-6 py-3">
<div className="flex items-start gap-2">
<ListChecks className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Reasoning</p>
<p className="text-sm leading-relaxed">{aiReasoning?.reasoning}</p>
</div>
</div>
</div>
</td>
</tr>
</CollapsibleContent>
)}
</>
</Collapsible>
)
})}
</TableBody>
</Table>
{eligibilityData.totalPages > 1 && (
<div className="p-4 border-t">
<Pagination
page={eligibilityData.page}
totalPages={eligibilityData.totalPages}
total={eligibilityData.total}
perPage={eligibilityPerPage}
onPageChange={setEligibilityPage}
/>
</div>
)}
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<ListChecks className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="text-lg font-medium">No eligibility data yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
{award.useAiEligibility
? 'Run AI eligibility to automatically evaluate projects against this award\'s criteria, or manually add projects.'
: 'Load all eligible projects into the evaluation list, or manually add specific projects.'}
</p>
<div className="flex gap-2 mt-4">
<Button onClick={handleRunEligibility} disabled={runEligibility.isPending || isPollingJob} size="sm">
{award.useAiEligibility ? (
<><ListChecks className="mr-2 h-4 w-4" />Run AI Eligibility</>
) : (
<><CheckCircle2 className="mr-2 h-4 w-4" />Load Projects</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddProjectDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Manually
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* Jurors Tab */}
<TabsContent value="jurors" className="space-y-4">
<div className="flex gap-2">
<Select value={selectedJurorId} onValueChange={setSelectedJurorId}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Select a juror..." />
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleAddJuror}
disabled={!selectedJurorId || addJuror.isPending}
>
<UserPlus className="mr-2 h-4 w-4" />
Add Juror
</Button>
</div>
{jurors && jurors.length > 0 ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jurors.map((j) => (
<TableRow key={j.id}>
<TableCell>
<div className="flex items-center gap-3">
<UserAvatar user={j.user} size="sm" />
<div>
<p className="font-medium">
{j.user.name || 'Unnamed'}
</p>
<p className="text-sm text-muted-foreground">
{j.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{j.user.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveJuror(j.userId)}
disabled={removeJuror.isPending}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Users className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="text-lg font-medium">No jurors assigned</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
Add jury members who will vote on eligible projects for this award. Select from existing jury members above.
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
</p>
</div>
)}
{!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Link this award to a competition first before creating rounds.
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" disabled={!award.competitionId}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Award Round</DialogTitle>
<DialogDescription>
Add a new round to the &quot;{award.name}&quot; award evaluation track.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="round-name">Round Name</Label>
<Input
id="round-name"
placeholder="e.g. Award Evaluation"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="round-type">Round Type</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger id="round-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVALUATION">Evaluation</SelectItem>
<SelectItem value="FILTERING">Filtering</SelectItem>
<SelectItem value="SUBMISSION">Submission</SelectItem>
<SelectItem value="MENTORING">Mentoring</SelectItem>
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button
onClick={() => createRound.mutate({
awardId,
name: roundForm.name.trim(),
roundType: roundForm.roundType as any,
})}
disabled={!roundForm.name.trim() || createRound.isPending}
>
{createRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
) : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{!awardRounds ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
) : awardRounds.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Create your first award round to build an evaluation track.
</CardContent>
</Card>
) : (
<RoundsDndGrid
rounds={awardRounds}
awardId={awardId}
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
onDelete={(roundId) => deleteRound.mutate({ roundId })}
isDeleting={deleteRound.isPending}
/>
)}
</TabsContent>
{/* Results Tab */}
<TabsContent value="results" className="space-y-4">
{voteResults && voteResults.results.length > 0 ? (() => {
const maxPoints = Math.max(...voteResults.results.map((r) => r.points), 1)
return (
<>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{voteResults.votedJurorCount} of {voteResults.jurorCount}{' '}
jurors voted
</span>
<Badge variant="outline">
{voteResults.scoringMode.replace('_', ' ')}
</Badge>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Project</TableHead>
<TableHead>Votes</TableHead>
<TableHead className="min-w-[200px]">Score</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{voteResults.results.map((r, i) => {
const isWinner = r.project.id === voteResults.winnerId
const barPercent = (r.points / maxPoints) * 100
return (
<TableRow
key={r.project.id}
className={isWinner ? 'bg-amber-50/80 dark:bg-amber-950/20' : ''}
>
<TableCell>
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
i === 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
: i === 1
? 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300'
: i === 2
? 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300'
: 'text-muted-foreground'
}`}>
{i + 1}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{isWinner && (
<Crown className="h-4 w-4 text-amber-500 flex-shrink-0" />
)}
<div>
<p className="font-medium">{r.project.title}</p>
<p className="text-sm text-muted-foreground">
{r.project.teamName}
</p>
</div>
</div>
</TableCell>
<TableCell>
<span className="tabular-nums">{r.votes}</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isWinner
? 'bg-gradient-to-r from-amber-400 to-amber-500'
: i === 0
? 'bg-gradient-to-r from-brand-blue to-brand-teal'
: 'bg-brand-teal/60'
}`}
style={{ width: `${barPercent}%` }}
/>
</div>
<span className="text-sm font-semibold tabular-nums w-10 text-right">
{r.points}
</span>
</div>
</TableCell>
<TableCell className="text-right">
{!isWinner && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetWinner(r.project.id)}
disabled={setWinner.isPending}
>
<Crown className="mr-1 h-3 w-3" />
Set Winner
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
</>
)
})() : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<BarChart3 className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="text-lg font-medium">No votes yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
{award._count.jurors === 0
? 'Assign jurors to this award first, then open voting to collect their selections.'
: award.status === 'DRAFT' || award.status === 'NOMINATIONS_OPEN'
? 'Open voting to allow jurors to submit their selections for this award.'
: 'Votes will appear here as jurors submit their selections.'}
</p>
{award.status === 'NOMINATIONS_OPEN' && (
<Button
className="mt-4"
size="sm"
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
)}
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</AnimatedCard>
</div>
)
}