diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 127e352..c9800d6 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -28,7 +28,9 @@ import { Waves, Send, Trophy, + FileText, } from 'lucide-react' +import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents' import { formatDateOnly } from '@/lib/utils' import { CountdownTimer } from '@/components/shared/countdown-timer' import { AnimatedCard } from '@/components/shared/animated-container' @@ -42,6 +44,70 @@ function getGreeting(): string { return 'Good evening' } +/** + * Prominent entry point to the finalist documents review, shown only to + * Grand-Final jury members (and admins). Rendered at the top of the dashboard + * regardless of whether the juror has individual assignments, so finals jurors + * can always find the teams' files in one obvious place. + */ +async function FinalsJuryBanner() { + const session = await auth() + const userId = session?.user?.id + if (!userId) return null + + const program = await prisma.program.findFirst({ + where: { status: 'ACTIVE' }, + orderBy: { year: 'desc' }, + select: { id: true }, + }) + if (!program) return null + + const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id) + if (!canReview) return null + + const round = await getOpenFinaleRound(prisma, program.id) + const teamCount = round + ? await prisma.projectRoundState.count({ where: { roundId: round.id } }) + : 0 + + return ( + + +
+ +
+
+ +
+
+

+ Grand Final +

+

Finalist Documents

+

+ {teamCount > 0 ? `All ${teamCount} finalist teams’ ` : 'Every finalist team’s '} + pitch decks, business plans, executive summaries and videos — in one place. +

+
+
+ +
+
+
+
+ ) +} + async function JuryDashboardContent() { const session = await auth() const userId = session?.user?.id @@ -863,6 +929,11 @@ export default async function JuryDashboardPage() { {/* Preferences banner (shown when juror has unconfirmed preferences) */} + {/* Grand-Final finalist documents — prominent entry for finals jurors */} + + + + {/* Content */} }> diff --git a/src/components/layouts/jury-nav.tsx b/src/components/layouts/jury-nav.tsx index 58d4a2e..5aa7a3e 100644 --- a/src/components/layouts/jury-nav.tsx +++ b/src/components/layouts/jury-nav.tsx @@ -47,6 +47,9 @@ export function JuryNav({ user }: JuryNavProps) { { refetchInterval: 60000 } ) const { data: flags } = trpc.settings.getFeatureFlags.useQuery() + // Only Grand-Final jury members (and admins) can open the finalist documents + // review — hide the link from everyone else so they don't hit a dead "No access" page. + const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery() const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl @@ -61,11 +64,15 @@ export function JuryNav({ user }: JuryNavProps) { href: '/jury/competitions', icon: ClipboardList, }, - { - name: 'Finalist Documents', - href: '/jury/finals-documents', - icon: FileText, - }, + ...(canReviewFinals + ? [ + { + name: 'Finalist Documents', + href: '/jury/finals-documents', + icon: FileText, + }, + ] + : []), ...(myAwards && myAwards.length > 0 ? [ { diff --git a/src/server/routers/finalist.ts b/src/server/routers/finalist.ts index 73c1ad0..253ef6a 100644 --- a/src/server/routers/finalist.ts +++ b/src/server/routers/finalist.ts @@ -1683,6 +1683,17 @@ export const finalistRouter = router({ }), /** Read-only review of all finalists' grand-final documents (admins + finale jury). */ + /** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */ + canReviewDocuments: protectedProcedure.query(async ({ ctx }) => { + const program = await ctx.prisma.program.findFirst({ + where: { status: 'ACTIVE' }, + orderBy: { year: 'desc' }, + select: { id: true }, + }) + if (!program) return false + return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id) + }), + listReviewDocuments: protectedProcedure .input(z.object({ programId: z.string() })) .query(async ({ ctx, input }) => {