feat: show awards on jury dashboard and add project details to award voting
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m27s

- Jury dashboard now shows active award voting banners with project
  count, deadline countdown, and direct link to vote
- Award voting page shows full project details: description, team
  members, tags, and downloadable files in expandable cards
- Award jurors can now download files for eligible projects (added
  awardJuror access check to file.getDownloadUrl)
- Backend query enhanced to include files and team members

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-14 12:28:53 -04:00
parent acd75427b3
commit 0987d49817
4 changed files with 394 additions and 86 deletions

View File

@@ -20,9 +20,21 @@ import {
CheckCircle2,
Loader2,
GripVertical,
ChevronDown,
FileText,
Download,
Users,
MapPin,
Tag,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Separator } from '@/components/ui/separator'
export default function JuryAwardVotingPage({
params,
@@ -45,6 +57,30 @@ export default function JuryAwardVotingPage({
null
)
const [rankedIds, setRankedIds] = useState<string[]>([])
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set())
const toggleExpanded = (projectId: string) => {
setExpandedProjects((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const handleDownload = async (objectKey: string, fileName: string) => {
try {
const result = await utils.file.getDownloadUrl.fetch({
bucket: 'mopc',
objectKey,
fileName,
forDownload: true,
})
if (result.url) window.open(result.url, '_blank')
} catch {
toast.error('Failed to download file')
}
}
// Initialize from existing votes
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
@@ -165,39 +201,19 @@ export default function JuryAwardVotingPage({
/* PICK_WINNER Mode */
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select one project as the winner
Select one project as the winner. Click the project header to expand details.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-3">
{projects.map((project) => (
<Card
<ProjectCard
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">
<CountryDisplay country={project.country} />
</Badge>
)}
</div>
</CardContent>
</Card>
project={project}
isSelected={selectedProjectId === project.id}
isExpanded={expandedProjects.has(project.id)}
onSelect={() => setSelectedProjectId(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
/>
))}
</div>
<div className="flex justify-end">
@@ -219,42 +235,51 @@ export default function JuryAwardVotingPage({
<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.
to add/remove from your rankings. Expand each project to see full details.
</p>
{/* Selected rankings */}
{rankedIds.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Your Rankings</CardTitle>
<CardTitle className="text-base">Your Rankings ({rankedIds.length}/{award.maxRankedPicks || 5})</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>
<Collapsible key={id} open={expandedProjects.has(id)}>
<div className="flex items-center gap-3 rounded-lg border p-3">
<span className="font-bold text-lg w-8 text-center text-brand-blue">
{index + 1}
</span>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && <> &middot; <CountryDisplay country={project.country} /></>}
</p>
</div>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" onClick={() => toggleExpanded(id)}>
<ChevronDown className={cn('h-4 w-4 transition-transform', expandedProjects.has(id) && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRanked(id)}
className="text-destructive hover:text-destructive"
>
Remove
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRanked(id)}
>
Remove
</Button>
</div>
<CollapsibleContent>
<ProjectDetails project={project} onDownload={handleDownload} />
</CollapsibleContent>
</Collapsible>
)
})}
</CardContent>
@@ -262,36 +287,20 @@ export default function JuryAwardVotingPage({
)}
{/* Available projects */}
<div className="grid gap-3 sm:grid-cols-2">
<h3 className="text-sm font-medium text-muted-foreground">Available Projects</h3>
<div className="space-y-3">
{projects
.filter((p) => !rankedIds.includes(p.id))
.map((project) => (
<Card
<ProjectCard
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">
<CountryDisplay country={project.country} />
</Badge>
)}
</div>
</CardContent>
</Card>
project={project}
isExpanded={expandedProjects.has(project.id)}
onSelect={() => toggleRanked(project.id)}
onToggleExpand={() => toggleExpanded(project.id)}
onDownload={handleDownload}
selectLabel={rankedIds.length < (award.maxRankedPicks || 5) ? `Add to #${rankedIds.length + 1}` : undefined}
/>
))}
</div>
@@ -325,3 +334,182 @@ export default function JuryAwardVotingPage({
</div>
)
}
/* ── Shared project detail components ── */
type ProjectData = {
id: string
title: string
teamName: string | null
description: string | null
competitionCategory: string | null
country: string | null
tags: string[]
files: Array<{
id: string
fileName: string
fileType: string
bucket: string
objectKey: string
createdAt: Date
}>
teamMembers: Array<{
id: string
role: string
user: { name: string | null; email: string }
}>
}
function ProjectDetails({
project,
onDownload,
}: {
project: ProjectData
onDownload: (storageKey: string, fileName: string) => void
}) {
return (
<div className="px-4 pb-4 pt-2 space-y-3 border-t mt-2">
{project.description && (
<div>
<p className="text-sm text-muted-foreground whitespace-pre-line">{project.description}</p>
</div>
)}
{project.tags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<Tag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{project.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
{project.teamMembers.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Team Members</span>
</div>
<div className="flex flex-wrap gap-2">
{project.teamMembers.map((m) => (
<Badge key={m.id} variant="outline" className="text-xs">
{m.user.name || m.user.email}
{m.role !== 'MEMBER' && ` (${m.role.toLowerCase()})`}
</Badge>
))}
</div>
</div>
)}
{project.files.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
Documents ({project.files.length})
</span>
</div>
<div className="space-y-1">
{project.files.map((file) => (
<button
key={file.id}
onClick={(e) => {
e.stopPropagation()
onDownload(file.objectKey, file.fileName)
}}
className="flex items-center gap-2 w-full text-left rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors group"
>
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate flex-1">{file.fileName}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{file.fileType.replace('_', ' ')}
</Badge>
<Download className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
</div>
)}
{!project.description && project.tags.length === 0 && project.files.length === 0 && project.teamMembers.length === 0 && (
<p className="text-sm text-muted-foreground italic">No additional details available</p>
)}
</div>
)
}
function ProjectCard({
project,
isSelected,
isExpanded,
onSelect,
onToggleExpand,
onDownload,
selectLabel,
}: {
project: ProjectData
isSelected?: boolean
isExpanded: boolean
onSelect: () => void
onToggleExpand: () => void
onDownload: (storageKey: string, fileName: string) => void
selectLabel?: string
}) {
return (
<Card
className={cn(
'transition-all',
isSelected ? 'ring-2 ring-primary bg-primary/5' : ''
)}
>
<div className="flex items-start gap-3 p-4">
<div className="flex-1 min-w-0">
<button
onClick={onToggleExpand}
className="flex items-center gap-2 text-left w-full group"
>
<ChevronDown className={cn(
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-180'
)} />
<div className="min-w-0">
<h3 className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">
{project.title}
</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{project.teamName}
{project.country && <> &middot; <CountryDisplay country={project.country} /></>}
</p>
</div>
</button>
<div className="flex flex-wrap gap-1 mt-2 ml-6">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace('_', ' ')}
</Badge>
)}
{project.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<FileText className="mr-1 h-3 w-3" />
{project.files.length} file{project.files.length !== 1 ? 's' : ''}
</Badge>
)}
</div>
</div>
<Button
size="sm"
variant={isSelected ? 'default' : 'outline'}
onClick={(e) => { e.stopPropagation(); onSelect() }}
className="shrink-0"
>
{isSelected ? (
<><CheckCircle2 className="mr-1 h-3.5 w-3.5" /> Selected</>
) : (
selectLabel || 'Select'
)}
</Button>
</div>
{isExpanded && <ProjectDetails project={project} onDownload={onDownload} />}
</Card>
)
}

View File

@@ -27,6 +27,7 @@ import {
BarChart3,
Waves,
Send,
Trophy,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
@@ -49,8 +50,8 @@ async function JuryDashboardContent() {
return null
}
// Get assignments, grace periods, and feature flags in parallel
const [assignments, gracePeriods, compareFlag] = await Promise.all([
// Get assignments, grace periods, feature flags, and award juror records in parallel
const [assignments, gracePeriods, compareFlag, myAwardJurorRecords] = await Promise.all([
prisma.assignment.findMany({
where: { userId },
include: {
@@ -109,10 +110,36 @@ async function JuryDashboardContent() {
},
}),
prisma.systemSettings.findUnique({ where: { key: 'jury_compare_enabled' } }),
prisma.awardJuror.findMany({
where: { userId },
include: {
award: {
select: {
id: true,
name: true,
description: true,
status: true,
votingStartAt: true,
votingEndAt: true,
scoringMode: true,
maxRankedPicks: true,
_count: { select: { eligibilities: { where: { eligible: true } } } },
},
},
},
}),
])
const juryCompareEnabled = compareFlag?.value === 'true'
// Awards where voting is open
const activeAwards = myAwardJurorRecords.filter(
(r) => r.award.status === 'VOTING_OPEN'
)
const upcomingAwards = myAwardJurorRecords.filter(
(r) => r.award.status === 'NOMINATIONS_OPEN'
)
// Calculate stats
const totalAssignments = assignments.length
const completedAssignments = assignments.filter(
@@ -216,8 +243,8 @@ async function JuryDashboardContent() {
},
]
// Zero-assignment state: compact welcome card
if (totalAssignments === 0) {
// Zero-assignment state: compact welcome card (but still show awards if any)
if (totalAssignments === 0 && activeAwards.length === 0 && upcomingAwards.length === 0) {
return (
<AnimatedCard index={0}>
<Card className="overflow-hidden">
@@ -268,6 +295,67 @@ async function JuryDashboardContent() {
return (
<>
{/* Active Awards Banner */}
{activeAwards.length > 0 && (
<AnimatedCard index={0}>
<Card className="overflow-hidden border-0 shadow-lg">
<div className="bg-gradient-to-r from-amber-400 to-amber-500 p-[1px] rounded-lg">
<div className="rounded-[7px] bg-background">
<CardHeader className="pb-2 pt-4 px-5">
<div className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/40">
<Trophy className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<CardTitle className="text-lg">Special Awards Voting Open</CardTitle>
</div>
</CardHeader>
<CardContent className="px-5 pb-4 space-y-3">
{activeAwards.map((record) => {
const award = record.award
const deadline = award.votingEndAt ? new Date(award.votingEndAt) : null
const isUrgent = deadline && (deadline.getTime() - now.getTime()) < 24 * 60 * 60 * 1000
return (
<div
key={award.id}
className={cn(
'rounded-xl border p-4 space-y-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isUrgent
? 'border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20'
: 'border-amber-200/60 bg-amber-50/30 dark:border-amber-800/40 dark:bg-amber-950/10'
)}
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-amber-700 dark:text-amber-400">{award.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{award._count.eligibilities} project{award._count.eligibilities !== 1 ? 's' : ''} to review
{record.isChair && ' · You are the Chair'}
</p>
</div>
<Badge className="bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700">
Vote Now
</Badge>
</div>
{deadline && (
<CountdownTimer deadline={deadline} label="Voting closes:" />
)}
<Button asChild size="sm" className="w-full bg-amber-600 hover:bg-amber-700 text-white shadow-sm">
<Link href={`/jury/awards/${award.id}`}>
Review & Vote
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)
})}
</CardContent>
</div>
</div>
</Card>
</AnimatedCard>
)}
{/* Hero CTA - Jump to next evaluation */}
{nextUnevaluated && activeRemaining > 0 && (
<AnimatedCard index={0}>

View File

@@ -39,7 +39,7 @@ export const fileRouter = router({
})
}
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
const [juryAssignment, mentorAssignment, teamMembership, awardJurorAccess] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true, roundId: true },
@@ -58,16 +58,29 @@ export const fileRouter = router({
},
select: { id: true },
}),
// Award jurors can access files for projects eligible in their awards
ctx.prisma.awardJuror.findFirst({
where: {
userId: ctx.user.id,
award: {
status: { in: ['VOTING_OPEN', 'NOMINATIONS_OPEN'] },
eligibilities: {
some: { projectId: file.projectId, eligible: true },
},
},
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment && !teamMembership) {
if (!juryAssignment && !mentorAssignment && !teamMembership && !awardJurorAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
if (juryAssignment && !mentorAssignment && !teamMembership) {
if (juryAssignment && !mentorAssignment && !teamMembership && !awardJurorAccess) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { competitionId: true, sortOrder: true },

View File

@@ -740,6 +740,25 @@ export const specialAwardRouter = router({
competitionCategory: true,
country: true,
tags: true,
files: {
where: { replacedById: null },
select: {
id: true,
fileName: true,
fileType: true,
bucket: true,
objectKey: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
},
teamMembers: {
select: {
id: true,
role: true,
user: { select: { name: true, email: true } },
},
},
},
},
},