Files
MOPC-Portal/src/app/(admin)/admin/awards/[id]/page.tsx

1632 lines
67 KiB
TypeScript
Raw Normal View History

'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 } =
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
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'}>
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
{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 "Selected 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>
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
<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">
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
{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>
)
}