feat(finals): prominent finalist-docs banner on jury dashboard + gate nav to finals jury
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m37s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m37s
Grand-Final jurors couldn't find the finalist documents review: the only entry point was a top-nav text link (hidden in the hamburger on mobile), with nothing on the dashboard. Add a prominent dashboard banner (shown only to finals-jury members) linking to /jury/finals-documents, and gate the nav "Finalist Documents" link to members so other jurors don't hit a dead "No access" page. - finalist.canReviewDocuments: lightweight boolean procedure (self-resolves active program) so the nav can gate the link without fetching the full payload - jury-nav: show "Finalist Documents" only when canReviewDocuments - jury dashboard: FinalsJuryBanner server component, gated via userCanReviewFinals, rendered above content regardless of assignment state Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,9 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
Send,
|
Send,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -42,6 +44,70 @@ function getGreeting(): string {
|
|||||||
return 'Good evening'
|
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 (
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="overflow-hidden border-0 shadow-lg">
|
||||||
|
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
|
||||||
|
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
|
||||||
|
<Trophy className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
|
||||||
|
Grand Final
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
|
||||||
|
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
|
||||||
|
{teamCount > 0 ? `All ${teamCount} finalist teams’ ` : 'Every finalist team’s '}
|
||||||
|
pitch decks, business plans, executive summaries and videos — in one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
|
||||||
|
>
|
||||||
|
<Link href="/jury/finals-documents">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Review Finalist Documents
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function JuryDashboardContent() {
|
async function JuryDashboardContent() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const userId = session?.user?.id
|
const userId = session?.user?.id
|
||||||
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
|
|||||||
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||||
<JuryPreferencesBanner />
|
<JuryPreferencesBanner />
|
||||||
|
|
||||||
|
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FinalsJuryBanner />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Suspense fallback={<DashboardSkeleton />}>
|
<Suspense fallback={<DashboardSkeleton />}>
|
||||||
<JuryDashboardContent />
|
<JuryDashboardContent />
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
{ refetchInterval: 60000 }
|
{ refetchInterval: 60000 }
|
||||||
)
|
)
|
||||||
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
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
|
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||||
|
|
||||||
@@ -61,11 +64,15 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
href: '/jury/competitions',
|
href: '/jury/competitions',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
...(canReviewFinals
|
||||||
name: 'Finalist Documents',
|
? [
|
||||||
href: '/jury/finals-documents',
|
{
|
||||||
icon: FileText,
|
name: 'Finalist Documents',
|
||||||
},
|
href: '/jury/finals-documents',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(myAwards && myAwards.length > 0
|
...(myAwards && myAwards.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1683,6 +1683,17 @@ export const finalistRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */
|
/** 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
|
listReviewDocuments: protectedProcedure
|
||||||
.input(z.object({ programId: z.string() }))
|
.input(z.object({ programId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user