2026-02-14 15:26:42 +01:00
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { use, useState } from 'react'
|
|
|
|
|
import Link from 'next/link'
|
|
|
|
|
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 { toast } from 'sonner'
|
|
|
|
|
import {
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
Trophy,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Loader2,
|
|
|
|
|
GripVertical,
|
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
|
|
|
|
|
export default function JuryAwardVotingPage({
|
|
|
|
|
params,
|
|
|
|
|
}: {
|
|
|
|
|
params: Promise<{ id: string }>
|
|
|
|
|
}) {
|
|
|
|
|
const { id: awardId } = use(params)
|
|
|
|
|
|
|
|
|
|
const utils = trpc.useUtils()
|
|
|
|
|
const { data, isLoading, refetch } =
|
|
|
|
|
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
|
|
|
|
const submitVote = trpc.specialAward.submitVote.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
|
|
|
|
null
|
|
|
|
|
)
|
|
|
|
|
const [rankedIds, setRankedIds] = useState<string[]>([])
|
|
|
|
|
|
|
|
|
|
// Initialize from existing votes
|
|
|
|
|
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
|
|
|
|
if (data.award.scoringMode === 'PICK_WINNER') {
|
|
|
|
|
setSelectedProjectId(data.myVotes[0]?.projectId || null)
|
|
|
|
|
} else if (data.award.scoringMode === 'RANKED') {
|
|
|
|
|
const sorted = [...data.myVotes]
|
|
|
|
|
.sort((a, b) => (a.rank || 0) - (b.rank || 0))
|
|
|
|
|
.map((v) => v.projectId)
|
|
|
|
|
setRankedIds(sorted)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSubmitPickWinner = async () => {
|
|
|
|
|
if (!selectedProjectId) return
|
|
|
|
|
try {
|
|
|
|
|
await submitVote.mutateAsync({
|
|
|
|
|
awardId,
|
|
|
|
|
votes: [{ projectId: selectedProjectId }],
|
|
|
|
|
})
|
|
|
|
|
toast.success('Vote submitted')
|
|
|
|
|
refetch()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(
|
|
|
|
|
error instanceof Error ? error.message : 'Failed to submit vote'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSubmitRanked = async () => {
|
|
|
|
|
if (rankedIds.length === 0) return
|
|
|
|
|
try {
|
|
|
|
|
await submitVote.mutateAsync({
|
|
|
|
|
awardId,
|
|
|
|
|
votes: rankedIds.map((projectId, index) => ({
|
|
|
|
|
projectId,
|
|
|
|
|
rank: index + 1,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
toast.success('Rankings submitted')
|
|
|
|
|
refetch()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(
|
|
|
|
|
error instanceof Error ? error.message : 'Failed to submit rankings'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toggleRanked = (projectId: string) => {
|
|
|
|
|
if (rankedIds.includes(projectId)) {
|
|
|
|
|
setRankedIds(rankedIds.filter((id) => id !== projectId))
|
|
|
|
|
} else {
|
|
|
|
|
const maxPicks = data?.award.maxRankedPicks || 5
|
|
|
|
|
if (rankedIds.length < maxPicks) {
|
|
|
|
|
setRankedIds([...rankedIds, projectId])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<Skeleton className="h-9 w-48" />
|
|
|
|
|
<Skeleton className="h-96 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data) return null
|
|
|
|
|
|
|
|
|
|
const { award, projects, myVotes } = data
|
|
|
|
|
const hasVoted = myVotes.length > 0
|
|
|
|
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<Button variant="ghost" asChild className="-ml-4">
|
|
|
|
|
<Link href="/jury/awards">
|
|
|
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
|
|
|
Back to Awards
|
|
|
|
|
</Link>
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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={isVotingOpen ? 'default' : 'secondary'}
|
|
|
|
|
>
|
|
|
|
|
{award.status.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
{hasVoted && (
|
|
|
|
|
<Badge variant="outline" className="text-green-600">
|
|
|
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
|
|
|
Voted
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{award.criteriaText && (
|
|
|
|
|
<p className="text-muted-foreground mt-2">{award.criteriaText}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!isVotingOpen ? (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 font-medium">Voting is not open</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Check back when voting opens for this award
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
) : award.scoringMode === 'PICK_WINNER' ? (
|
|
|
|
|
/* PICK_WINNER Mode */
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Select one project as the winner
|
|
|
|
|
</p>
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
{projects.map((project) => (
|
|
|
|
|
<Card
|
|
|
|
|
key={project.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
'cursor-pointer transition-all',
|
|
|
|
|
selectedProjectId === project.id
|
|
|
|
|
? 'ring-2 ring-primary bg-primary/5'
|
|
|
|
|
: 'hover:bg-muted/50'
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => setSelectedProjectId(project.id)}
|
|
|
|
|
>
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<CardTitle className="text-base">{project.title}</CardTitle>
|
|
|
|
|
<CardDescription>{project.teamName}</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{project.competitionCategory && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{project.competitionCategory.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{project.country && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{project.country}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSubmitPickWinner}
|
|
|
|
|
disabled={!selectedProjectId || submitVote.isPending}
|
|
|
|
|
>
|
|
|
|
|
{submitVote.isPending ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : award.scoringMode === 'RANKED' ? (
|
|
|
|
|
/* RANKED Mode */
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Select and rank your top {award.maxRankedPicks || 5} projects. Click
|
|
|
|
|
to add/remove, drag to reorder.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* Selected rankings */}
|
|
|
|
|
{rankedIds.length > 0 && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<CardTitle className="text-base">Your Rankings</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-2">
|
|
|
|
|
{rankedIds.map((id, index) => {
|
|
|
|
|
const project = projects.find((p) => p.id === id)
|
|
|
|
|
if (!project) return null
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={id}
|
|
|
|
|
className="flex items-center gap-3 rounded-lg border p-3"
|
|
|
|
|
>
|
|
|
|
|
<span className="font-bold text-lg w-8 text-center">
|
|
|
|
|
{index + 1}
|
|
|
|
|
</span>
|
|
|
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<p className="font-medium">{project.title}</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{project.teamName}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => toggleRanked(id)}
|
|
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Available projects */}
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
{projects
|
|
|
|
|
.filter((p) => !rankedIds.includes(p.id))
|
|
|
|
|
.map((project) => (
|
|
|
|
|
<Card
|
|
|
|
|
key={project.id}
|
|
|
|
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
|
|
|
onClick={() => toggleRanked(project.id)}
|
|
|
|
|
>
|
|
|
|
|
<CardHeader className="pb-2">
|
|
|
|
|
<CardTitle className="text-base">
|
|
|
|
|
{project.title}
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>{project.teamName}</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{project.competitionCategory && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{project.competitionCategory.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{project.country && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
{project.country}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSubmitRanked}
|
|
|
|
|
disabled={rankedIds.length === 0 || submitVote.isPending}
|
|
|
|
|
>
|
|
|
|
|
{submitVote.isPending ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
{hasVoted ? 'Update Rankings' : 'Submit Rankings'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
/* SCORED Mode — redirect to evaluation */
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<Trophy className="h-12 w-12 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 font-medium">Scored Award</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
This award uses the evaluation system. Check your evaluation
|
|
|
|
|
assignments.
|
|
|
|
|
</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|