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
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:
@@ -20,9 +20,21 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CountryDisplay } from '@/components/shared/country-display'
|
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({
|
export default function JuryAwardVotingPage({
|
||||||
params,
|
params,
|
||||||
@@ -45,6 +57,30 @@ export default function JuryAwardVotingPage({
|
|||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [rankedIds, setRankedIds] = useState<string[]>([])
|
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
|
// Initialize from existing votes
|
||||||
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
if (data && !selectedProjectId && !rankedIds.length && data.myVotes.length > 0) {
|
||||||
@@ -165,39 +201,19 @@ export default function JuryAwardVotingPage({
|
|||||||
/* PICK_WINNER Mode */
|
/* PICK_WINNER Mode */
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="space-y-3">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<Card
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={cn(
|
project={project}
|
||||||
'cursor-pointer transition-all',
|
isSelected={selectedProjectId === project.id}
|
||||||
selectedProjectId === project.id
|
isExpanded={expandedProjects.has(project.id)}
|
||||||
? 'ring-2 ring-primary bg-primary/5'
|
onSelect={() => setSelectedProjectId(project.id)}
|
||||||
: 'hover:bg-muted/50'
|
onToggleExpand={() => toggleExpanded(project.id)}
|
||||||
)}
|
onDownload={handleDownload}
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -219,42 +235,51 @@ export default function JuryAwardVotingPage({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Select and rank your top {award.maxRankedPicks || 5} projects. Click
|
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>
|
</p>
|
||||||
|
|
||||||
{/* Selected rankings */}
|
{/* Selected rankings */}
|
||||||
{rankedIds.length > 0 && (
|
{rankedIds.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base">Your Rankings</CardTitle>
|
<CardTitle className="text-base">Your Rankings ({rankedIds.length}/{award.maxRankedPicks || 5})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{rankedIds.map((id, index) => {
|
{rankedIds.map((id, index) => {
|
||||||
const project = projects.find((p) => p.id === id)
|
const project = projects.find((p) => p.id === id)
|
||||||
if (!project) return null
|
if (!project) return null
|
||||||
return (
|
return (
|
||||||
<div
|
<Collapsible key={id} open={expandedProjects.has(id)}>
|
||||||
key={id}
|
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
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 className="font-bold text-lg w-8 text-center">
|
</span>
|
||||||
{index + 1}
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
<div className="flex-1 min-w-0">
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
<p className="font-medium truncate">{project.title}</p>
|
||||||
<div className="flex-1">
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="font-medium">{project.title}</p>
|
{project.teamName}
|
||||||
<p className="text-sm text-muted-foreground">
|
{project.country && <> · <CountryDisplay country={project.country} /></>}
|
||||||
{project.teamName}
|
</p>
|
||||||
</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>
|
</div>
|
||||||
<Button
|
<CollapsibleContent>
|
||||||
variant="ghost"
|
<ProjectDetails project={project} onDownload={handleDownload} />
|
||||||
size="sm"
|
</CollapsibleContent>
|
||||||
onClick={() => toggleRanked(id)}
|
</Collapsible>
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -262,36 +287,20 @@ export default function JuryAwardVotingPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Available projects */}
|
{/* 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
|
{projects
|
||||||
.filter((p) => !rankedIds.includes(p.id))
|
.filter((p) => !rankedIds.includes(p.id))
|
||||||
.map((project) => (
|
.map((project) => (
|
||||||
<Card
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
project={project}
|
||||||
onClick={() => toggleRanked(project.id)}
|
isExpanded={expandedProjects.has(project.id)}
|
||||||
>
|
onSelect={() => toggleRanked(project.id)}
|
||||||
<CardHeader className="pb-2">
|
onToggleExpand={() => toggleExpanded(project.id)}
|
||||||
<CardTitle className="text-base">
|
onDownload={handleDownload}
|
||||||
{project.title}
|
selectLabel={rankedIds.length < (award.maxRankedPicks || 5) ? `Add to #${rankedIds.length + 1}` : undefined}
|
||||||
</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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,3 +334,182 @@ export default function JuryAwardVotingPage({
|
|||||||
</div>
|
</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 && <> · <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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Waves,
|
Waves,
|
||||||
Send,
|
Send,
|
||||||
|
Trophy,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
@@ -49,8 +50,8 @@ async function JuryDashboardContent() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get assignments, grace periods, and feature flags in parallel
|
// Get assignments, grace periods, feature flags, and award juror records in parallel
|
||||||
const [assignments, gracePeriods, compareFlag] = await Promise.all([
|
const [assignments, gracePeriods, compareFlag, myAwardJurorRecords] = await Promise.all([
|
||||||
prisma.assignment.findMany({
|
prisma.assignment.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: {
|
include: {
|
||||||
@@ -109,10 +110,36 @@ async function JuryDashboardContent() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.systemSettings.findUnique({ where: { key: 'jury_compare_enabled' } }),
|
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'
|
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
|
// Calculate stats
|
||||||
const totalAssignments = assignments.length
|
const totalAssignments = assignments.length
|
||||||
const completedAssignments = assignments.filter(
|
const completedAssignments = assignments.filter(
|
||||||
@@ -216,8 +243,8 @@ async function JuryDashboardContent() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Zero-assignment state: compact welcome card
|
// Zero-assignment state: compact welcome card (but still show awards if any)
|
||||||
if (totalAssignments === 0) {
|
if (totalAssignments === 0 && activeAwards.length === 0 && upcomingAwards.length === 0) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
@@ -268,6 +295,67 @@ async function JuryDashboardContent() {
|
|||||||
|
|
||||||
return (
|
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 */}
|
{/* Hero CTA - Jump to next evaluation */}
|
||||||
{nextUnevaluated && activeRemaining > 0 && (
|
{nextUnevaluated && activeRemaining > 0 && (
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
|
|||||||
@@ -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({
|
ctx.prisma.assignment.findFirst({
|
||||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||||
select: { id: true, roundId: true },
|
select: { id: true, roundId: true },
|
||||||
@@ -58,16 +58,29 @@ export const fileRouter = router({
|
|||||||
},
|
},
|
||||||
select: { id: true },
|
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({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'You do not have access to this file',
|
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({
|
const assignedRound = await ctx.prisma.round.findUnique({
|
||||||
where: { id: juryAssignment.roundId },
|
where: { id: juryAssignment.roundId },
|
||||||
select: { competitionId: true, sortOrder: true },
|
select: { competitionId: true, sortOrder: true },
|
||||||
|
|||||||
@@ -740,6 +740,25 @@ export const specialAwardRouter = router({
|
|||||||
competitionCategory: true,
|
competitionCategory: true,
|
||||||
country: true,
|
country: true,
|
||||||
tags: 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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user