2026-02-14 15:26:42 +01:00
|
|
|
'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 { 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,
|
|
|
|
|
Crown,
|
|
|
|
|
UserPlus,
|
|
|
|
|
X,
|
|
|
|
|
Play,
|
|
|
|
|
Lock,
|
|
|
|
|
Pencil,
|
|
|
|
|
Trash2,
|
|
|
|
|
Plus,
|
|
|
|
|
Search,
|
|
|
|
|
Vote,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
AlertCircle,
|
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
// 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 })
|
|
|
|
|
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',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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 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('_', ' ')}
|
|
|
|
|
</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
|
|
|
|
|
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 "{award.name}" 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._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="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._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'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">
|
|
|
|
|
{e.method === 'MANUAL' ? 'Manual' : 'Auto'}
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
)
|
|
|
|
|
}
|