From 8800f2bcc7fe6d39f1f2b8f1b396037d078feb58 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 6 Apr 2026 16:48:46 -0400 Subject: [PATCH] feat: add award master voting page with project detail, documents, scores, justification, and chair confirmation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../award-master/awards/[id]/page.tsx | 579 ++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 src/app/(award-master)/award-master/awards/[id]/page.tsx diff --git a/src/app/(award-master)/award-master/awards/[id]/page.tsx b/src/app/(award-master)/award-master/awards/[id]/page.tsx new file mode 100644 index 0000000..11debf0 --- /dev/null +++ b/src/app/(award-master)/award-master/awards/[id]/page.tsx @@ -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( + null + ) + const [expandedProjectId, setExpandedProjectId] = useState( + 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 ( +
+ + +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+
+ ) + } + + 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 ( +
+ {/* Back button */} +
+ +
+ + {/* Header */} +
+

+ + {award.name} +

+
+ + {award.status.replace('_', ' ')} + + {hasVoted && !isClosed && ( + + + Voted + + )} + {award.competition && ( + + {award.competition.name} + + )} +
+ {award.criteriaText && ( + + +

+ + Criteria: + {award.criteriaText} +

+
+
+ )} +
+ + {/* Closed State */} + {isClosed ? ( + + +
+ +
+

Award Finalized

+ {winnerProject ? ( +
+

+ {winnerProject.title} +

+ {winnerProject.teamName && ( +

+ {winnerProject.teamName} +

+ )} +
+ ) : ( +

+ This award has been finalized +

+ )} +
+
+ ) : ( + <> + {/* Project Grid */} +
+

+ Eligible Projects ({projects.length}) +

+ {isVotingOpen && ( +

+ Click a project to select it as your pick and expand details +

+ )} +
+ {projects.map((project) => ( +
+ handleProjectClick(project.id)} + > + +
+
+ + {project.title} + + {project.teamName && ( + + {project.teamName} + + )} +
+
+ {expandedProjectId === project.id ? ( + + ) : ( + + )} +
+
+
+ +
+ {project.competitionCategory && ( + + {project.competitionCategory.replace(/_/g, ' ')} + + )} + {project.country && ( + + + + )} + {project.evaluationScore && ( + + + Avg: {project.evaluationScore.avg.toFixed(1)}/10 ( + {project.evaluationScore.count}{' '} + {project.evaluationScore.count === 1 + ? 'review' + : 'reviews'} + ) + + )} + {selectedProjectId === project.id && ( + + + Selected + + )} +
+
+
+ + {/* Expanded Project Detail */} + {expandedProjectId === project.id && ( + + + {project.description && ( +
+

+ + Description +

+

+ {project.description} +

+
+ )} + + {award.evaluationRoundId && ( +
+

+ + Documents +

+ +
+ )} + + {project.evaluationScore && ( + + +
+ +
+

+ Evaluation Score +

+

+ {project.evaluationScore.avg.toFixed(1)} / 10 +

+

+ Based on {project.evaluationScore.count}{' '} + {project.evaluationScore.count === 1 + ? 'evaluation' + : 'evaluations'} +

+
+
+
+
+ )} +
+
+ )} +
+ ))} +
+
+ + {/* Vote Section */} + {isVotingOpen && ( + + + Your Vote + + {hasVoted + ? 'You can update your vote until the award is finalized' + : 'Select a project above and submit your vote'} + + + + {selectedProject ? ( +
+

+ Your selection +

+

{selectedProject.title}

+ {selectedProject.teamName && ( +

+ {selectedProject.teamName} +

+ )} +
+ ) : ( +

+ No project selected. Click a project card above to select it. +

+ )} + +
+ +