All checks were successful
Build and Push Docker Image / build (push) Successful in 7m47s
- Jury dashboard now shows "Submitted" badge (green) with "Edit Rankings" button when juror has already voted, instead of always showing "Vote Now" — prevents confusion about whether vote saved - Award-master page now shows project logos next to project names - Backend getMyAwardDetailEnhanced now returns logo URLs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
582 lines
22 KiB
TypeScript
582 lines
22 KiB
TypeScript
'use client'
|
|
|
|
import { use, useRef, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import type { Route } from 'next'
|
|
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 { Textarea } from '@/components/ui/textarea'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
ArrowLeft,
|
|
Trophy,
|
|
CheckCircle2,
|
|
Loader2,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
FileText,
|
|
Star,
|
|
Users,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { CountryDisplay } from '@/components/shared/country-display'
|
|
import { ProjectFilesSection } from '@/components/jury/project-files-section'
|
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
|
|
|
export default function AwardMasterVotingPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>
|
|
}) {
|
|
const { id: awardId } = use(params)
|
|
const router = useRouter()
|
|
|
|
// State
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
|
null
|
|
)
|
|
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
|
|
null
|
|
)
|
|
const [justification, setJustification] = useState('')
|
|
|
|
// Queries & mutations
|
|
const utils = trpc.useUtils()
|
|
const { data, isLoading } =
|
|
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
|
|
|
|
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
|
|
onSuccess: () => {
|
|
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
|
toast.success('Vote submitted')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
|
|
onSuccess: () => {
|
|
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
|
|
toast.success('Winner confirmed and award closed')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
// Initialize selection from existing vote
|
|
const initializedRef = useRef(false)
|
|
if (data && !initializedRef.current && data.myVotes.length > 0) {
|
|
initializedRef.current = true
|
|
setSelectedProjectId(data.myVotes[0].projectId)
|
|
if (data.myVotes[0].justification) {
|
|
setJustification(data.myVotes[0].justification)
|
|
}
|
|
}
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-9 w-48" />
|
|
<Skeleton className="h-6 w-72" />
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{[...Array(6)].map((_, i) => (
|
|
<Skeleton key={i} className="h-44" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data) return null
|
|
|
|
// Destructure data
|
|
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
|
|
const hasVoted = myVotes.length > 0
|
|
const isVotingOpen = award.status === 'VOTING_OPEN'
|
|
const isClosed = award.status === 'CLOSED'
|
|
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
|
|
|
// Toggle project expansion
|
|
const handleProjectClick = (projectId: string) => {
|
|
if (isVotingOpen) setSelectedProjectId(projectId)
|
|
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
|
|
}
|
|
|
|
// Submit vote handler
|
|
const handleSubmitVote = () => {
|
|
if (!selectedProjectId) return
|
|
submitVote.mutate({
|
|
awardId,
|
|
projectId: selectedProjectId,
|
|
justification: justification.trim() || undefined,
|
|
})
|
|
}
|
|
|
|
// Confirm winner handler
|
|
const handleConfirmWinner = () => {
|
|
confirmWinner.mutate({ awardId })
|
|
}
|
|
|
|
// Find the winner project for closed state
|
|
const winnerProject = isClosed
|
|
? projects.find((p) => p.id === award.winnerProjectId)
|
|
: null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Back button */}
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => router.push('/award-master' as Route)}
|
|
className="-ml-4"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<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="mt-1 flex items-center gap-2">
|
|
<Badge
|
|
variant={
|
|
isVotingOpen
|
|
? 'default'
|
|
: isClosed
|
|
? 'secondary'
|
|
: 'outline'
|
|
}
|
|
>
|
|
{award.status.replace('_', ' ')}
|
|
</Badge>
|
|
{hasVoted && !isClosed && (
|
|
<Badge variant="outline" className="text-green-600">
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
Voted
|
|
</Badge>
|
|
)}
|
|
{award.competition && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{award.competition.name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{award.criteriaText && (
|
|
<Card className="mt-3 bg-muted/30">
|
|
<CardContent className="py-3 px-4">
|
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
|
|
<span className="font-medium text-foreground">Criteria: </span>
|
|
{award.criteriaText}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Closed State */}
|
|
{isClosed ? (
|
|
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
|
|
<Trophy className="h-12 w-12 text-amber-500" />
|
|
</div>
|
|
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
|
|
{winnerProject ? (
|
|
<div className="mt-3 space-y-1">
|
|
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
|
|
{winnerProject.title}
|
|
</p>
|
|
{winnerProject.teamName && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{winnerProject.teamName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
This award has been finalized
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<>
|
|
{/* Project Grid */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-3">
|
|
Eligible Projects ({projects.length})
|
|
</h2>
|
|
{isVotingOpen && (
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Click a project to select it as your pick and expand details
|
|
</p>
|
|
)}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{projects.map((project) => (
|
|
<div
|
|
key={project.id}
|
|
className={cn(
|
|
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
|
|
)}
|
|
>
|
|
<Card
|
|
className={cn(
|
|
'cursor-pointer transition-all',
|
|
selectedProjectId === project.id
|
|
? 'ring-2 ring-primary bg-primary/5'
|
|
: 'hover:bg-muted/50'
|
|
)}
|
|
onClick={() => handleProjectClick(project.id)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start gap-3">
|
|
<ProjectLogo project={project} logoUrl={project.logoUrl} size="sm" fallback="initials" className="mt-0.5" />
|
|
<div className="flex-1 min-w-0">
|
|
<CardTitle className="text-base">
|
|
{project.title}
|
|
</CardTitle>
|
|
{project.teamName && (
|
|
<CardDescription className="mt-0.5">
|
|
{project.teamName}
|
|
</CardDescription>
|
|
)}
|
|
</div>
|
|
<div className="ml-2 shrink-0">
|
|
{expandedProjectId === project.id ? (
|
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{project.competitionCategory && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{project.competitionCategory.replace(/_/g, ' ')}
|
|
</Badge>
|
|
)}
|
|
{project.country && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<CountryDisplay country={project.country} />
|
|
</Badge>
|
|
)}
|
|
{project.evaluationScore && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
|
|
>
|
|
<Star className="mr-0.5 h-3 w-3" />
|
|
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
|
|
{project.evaluationScore.count}{' '}
|
|
{project.evaluationScore.count === 1
|
|
? 'review'
|
|
: 'reviews'}
|
|
)
|
|
</Badge>
|
|
)}
|
|
{selectedProjectId === project.id && (
|
|
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
|
|
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
|
Selected
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Expanded Project Detail */}
|
|
{expandedProjectId === project.id && (
|
|
<Card className="mt-2 border-dashed">
|
|
<CardContent className="space-y-4 py-4">
|
|
{project.description && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
Description
|
|
</h4>
|
|
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
|
|
{project.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{award.evaluationRoundId && (
|
|
<div>
|
|
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
Documents
|
|
</h4>
|
|
<ProjectFilesSection
|
|
projectId={project.id}
|
|
roundId={award.evaluationRoundId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{project.evaluationScore && (
|
|
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-center gap-2">
|
|
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
Evaluation Score
|
|
</p>
|
|
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
|
|
{project.evaluationScore.avg.toFixed(1)} / 10
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Based on {project.evaluationScore.count}{' '}
|
|
{project.evaluationScore.count === 1
|
|
? 'evaluation'
|
|
: 'evaluations'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Vote Section */}
|
|
{isVotingOpen && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Your Vote</CardTitle>
|
|
<CardDescription>
|
|
{hasVoted
|
|
? 'You can update your vote until the award is finalized'
|
|
: 'Select a project above and submit your vote'}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{selectedProject ? (
|
|
<div className="rounded-lg border bg-muted/30 p-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Your selection
|
|
</p>
|
|
<p className="font-semibold">{selectedProject.title}</p>
|
|
{selectedProject.teamName && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{selectedProject.teamName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">
|
|
No project selected. Click a project card above to select it.
|
|
</p>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="justification"
|
|
className="text-sm font-medium"
|
|
>
|
|
Justification
|
|
</label>
|
|
<Textarea
|
|
id="justification"
|
|
value={justification}
|
|
onChange={(e) => setJustification(e.target.value)}
|
|
placeholder="Why did you choose this project? (optional)"
|
|
maxLength={2000}
|
|
rows={4}
|
|
/>
|
|
<p className="text-xs text-muted-foreground text-right">
|
|
{justification.length} / 2000
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={handleSubmitVote}
|
|
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>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Chair Section */}
|
|
{isChair && isVotingOpen && (
|
|
<>
|
|
<Separator />
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Users className="h-5 w-5 text-muted-foreground" />
|
|
Team Votes
|
|
</CardTitle>
|
|
<CardDescription>
|
|
As chair, you can view team votes and confirm the winner
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{otherVotes.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{otherVotes.map((vote) => {
|
|
const votedProject = projects.find(
|
|
(p) => p.id === vote.projectId
|
|
)
|
|
return (
|
|
<div
|
|
key={vote.userId}
|
|
className="rounded-lg border p-3 space-y-1"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<p className="font-medium text-sm">
|
|
{vote.userName || 'Anonymous Juror'}
|
|
</p>
|
|
<Badge variant="outline" className="text-xs">
|
|
voted for
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm font-semibold">
|
|
{votedProject?.title || 'Unknown project'}
|
|
</p>
|
|
{vote.justification && (
|
|
<p className="text-sm text-muted-foreground italic">
|
|
“{vote.justification}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">
|
|
Waiting for other team members to vote
|
|
</p>
|
|
)}
|
|
|
|
{/* Vote tally */}
|
|
<div className="rounded-lg bg-muted/30 p-3">
|
|
<p className="text-sm font-medium">Vote Summary</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
|
|
{totalJurors} jurors have voted
|
|
</p>
|
|
{(() => {
|
|
const allVotes = [
|
|
...otherVotes.map((v) => v.projectId),
|
|
...(hasVoted && myVotes[0]
|
|
? [myVotes[0].projectId]
|
|
: []),
|
|
]
|
|
const tally = new Map<string, number>()
|
|
for (const pid of allVotes) {
|
|
tally.set(pid, (tally.get(pid) || 0) + 1)
|
|
}
|
|
const sorted = [...tally.entries()].sort(
|
|
(a, b) => b[1] - a[1]
|
|
)
|
|
if (sorted.length === 0) return null
|
|
return (
|
|
<div className="mt-2 space-y-1">
|
|
{sorted.map(([pid, count]) => {
|
|
const proj = projects.find((p) => p.id === pid)
|
|
return (
|
|
<div
|
|
key={pid}
|
|
className="flex items-center justify-between text-sm"
|
|
>
|
|
<span>{proj?.title || 'Unknown'}</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{count} {count === 1 ? 'vote' : 'votes'}
|
|
</Badge>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
{/* Confirm Winner button */}
|
|
<div className="flex justify-end">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="default"
|
|
disabled={!hasVoted || confirmWinner.isPending}
|
|
>
|
|
{confirmWinner.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trophy className="mr-2 h-4 w-4" />
|
|
)}
|
|
Confirm Winner
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Confirm Award Winner
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will finalize the winner and close the award.
|
|
This cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmWinner}>
|
|
Confirm Winner
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|