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 && (
|
{isGrandFinale && programId && (
|
||||||
<>
|
<>
|
||||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
<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} />
|
<FinalDocsReminderButton programId={programId} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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'
|
'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 { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -61,6 +61,11 @@ export function JuryNav({ user }: JuryNavProps) {
|
|||||||
href: '/jury/competitions',
|
href: '/jury/competitions',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Finalist Documents',
|
||||||
|
href: '/jury/finals-documents',
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
...(myAwards && myAwards.length > 0
|
...(myAwards && myAwards.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ export const competitionRouter = router({
|
|||||||
return competition
|
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
|
* Get competition by ID with rounds, jury groups, and submission windows
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user