feat(final-docs): judge review page + entry points
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1530,7 +1530,13 @@ export default function RoundDetailPage() {
|
||||
{isGrandFinale && programId && (
|
||||
<>
|
||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/jury/finals-documents">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Review finalist documents
|
||||
</Link>
|
||||
</Button>
|
||||
<FinalDocsReminderButton programId={programId} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
141
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
141
src/app/(jury)/jury/finals-documents/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { FilePreview } from '@/components/shared/file-viewer'
|
||||
import { FileText, Download, ShieldAlert } from 'lucide-react'
|
||||
|
||||
export default function FinalsDocumentsPage() {
|
||||
const { data: programId, isLoading: programLoading } =
|
||||
trpc.competition.getActiveProgramId.useQuery()
|
||||
const { data, isLoading, error } = trpc.finalist.listReviewDocuments.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId, retry: false },
|
||||
)
|
||||
|
||||
if (error?.data?.code === 'FORBIDDEN') {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<ShieldAlert className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No access</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This review is for the Grand-Final jury and program admins.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// No active program resolved — nothing to review.
|
||||
if (!programLoading && !programId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No active program</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Finalist documents will appear here once a program is active.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fmt = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue">
|
||||
Finalist Documents
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{data.submittedCount} of {data.totalCount} teams complete
|
||||
{data.round.deadline
|
||||
? ` · due ${fmt.format(new Date(data.round.deadline))}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
{data.teams.map((team) => (
|
||||
<Card key={team.projectId}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">{team.teamName}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{team.category && (
|
||||
<Badge variant="secondary">{team.category}</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant={team.submitted ? 'default' : 'outline'}
|
||||
className={
|
||||
team.submitted
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{team.submitted ? 'Complete' : 'Incomplete'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
{team.documents.map((doc) => (
|
||||
<div
|
||||
key={doc.requirementId}
|
||||
className="rounded-lg border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> {doc.requirementName}
|
||||
</span>
|
||||
{doc.file && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<a
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" /> Open
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{doc.file ? (
|
||||
<FilePreview
|
||||
file={{
|
||||
mimeType: doc.file.mimeType,
|
||||
fileName: doc.file.fileName,
|
||||
}}
|
||||
url={doc.file.url}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
Not yet uploaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { BookOpen, Home, Trophy, ClipboardList } from 'lucide-react'
|
||||
import { BookOpen, Home, Trophy, ClipboardList, FileText } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -61,6 +61,11 @@ export function JuryNav({ user }: JuryNavProps) {
|
||||
href: '/jury/competitions',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
name: 'Finalist Documents',
|
||||
href: '/jury/finals-documents',
|
||||
icon: FileText,
|
||||
},
|
||||
...(myAwards && myAwards.length > 0
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -54,6 +54,20 @@ export const competitionRouter = router({
|
||||
return competition
|
||||
}),
|
||||
|
||||
/**
|
||||
* Resolve the id of the most-recent ACTIVE program for the logged-in user.
|
||||
* Used by client pages (e.g. the finalist-documents judge review) that need a
|
||||
* programId but don't have one in the route. Returns null when none is active.
|
||||
*/
|
||||
getActiveProgramId: protectedProcedure.query(async ({ ctx }) => {
|
||||
const program = await ctx.prisma.program.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { year: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
return program?.id ?? null
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get competition by ID with rounds, jury groups, and submission windows
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user