feat: add award master voting page with project detail, documents, scores, justification, and chair confirmation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-06 16:48:46 -04:00
parent 0be8c5ecc7
commit 8800f2bcc7

View File

@@ -0,0 +1,579 @@
'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'
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 justify-between">
<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">
&ldquo;{vote.justification}&rdquo;
</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>
)
}