diff --git a/src/app/(jury)/jury/finals-documents/page.tsx b/src/app/(jury)/jury/finals-documents/page.tsx
new file mode 100644
index 0000000..3734e44
--- /dev/null
+++ b/src/app/(jury)/jury/finals-documents/page.tsx
@@ -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 (
+
+
+
+ No access
+
+ This review is for the Grand-Final jury and program admins.
+
+
+
+ )
+ }
+
+ // No active program resolved — nothing to review.
+ if (!programLoading && !programId) {
+ return (
+
+
+
+ No active program
+
+ Finalist documents will appear here once a program is active.
+
+
+
+ )
+ }
+
+ if (isLoading || !data) {
+ return (
+
+
+
+
+ )
+ }
+
+ const fmt = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'long',
+ timeStyle: 'short',
+ })
+ return (
+
+
+
+ Finalist Documents
+
+
+ {data.submittedCount} of {data.totalCount} teams complete
+ {data.round.deadline
+ ? ` · due ${fmt.format(new Date(data.round.deadline))}`
+ : ''}
+
+
+ {data.teams.map((team) => (
+
+
+ {team.teamName}
+
+ {team.category && (
+ {team.category}
+ )}
+
+ {team.submitted ? 'Complete' : 'Incomplete'}
+
+
+
+
+ {team.documents.map((doc) => (
+
+
+
+ {doc.requirementName}
+
+ {doc.file && (
+
+ )}
+
+ {doc.file ? (
+
+ ) : (
+
+ Not yet uploaded
+
+ )}
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/src/components/layouts/jury-nav.tsx b/src/components/layouts/jury-nav.tsx
index 82ae3bd..58d4a2e 100644
--- a/src/components/layouts/jury-nav.tsx
+++ b/src/components/layouts/jury-nav.tsx
@@ -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
? [
{
diff --git a/src/server/routers/competition.ts b/src/server/routers/competition.ts
index 8292cd7..3815cea 100644
--- a/src/server/routers/competition.ts
+++ b/src/server/routers/competition.ts
@@ -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
*/