diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index d40f535..072a2d9 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -1530,7 +1530,13 @@ export default function RoundDetailPage() { {isGrandFinale && programId && ( <> -
+
+
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 */