refactor(final-docs): shared review component reachable by jury + admin routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
7
src/app/(admin)/admin/finals-documents/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function AdminFinalsDocumentsPage() {
|
||||||
|
return <FinalsDocumentsReview />
|
||||||
|
}
|
||||||
@@ -1532,7 +1532,7 @@ export default function RoundDetailPage() {
|
|||||||
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
<FinalistEnrollmentCard programId={programId} roundId={roundId} />
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/jury/finals-documents">
|
<Link href="/admin/finals-documents">
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Review finalist documents
|
Review finalist documents
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,141 +1,7 @@
|
|||||||
'use client'
|
import { FinalsDocumentsReview } from '@/components/finals/finals-documents-review'
|
||||||
|
|
||||||
import { trpc } from '@/lib/trpc/client'
|
export const dynamic = 'force-dynamic'
|
||||||
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() {
|
export default function FinalsDocumentsPage() {
|
||||||
const { data: programId, isLoading: programLoading } =
|
return <FinalsDocumentsReview />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
147
src/components/finals/finals-documents-review.tsx
Normal file
147
src/components/finals/finals-documents-review.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only judge review of finalist grand-final documents. Self-resolves the
|
||||||
|
* active program and self-gates on the `finalist.listReviewDocuments` FORBIDDEN
|
||||||
|
* check, so it can be mounted on any route reachable by either the finals jury
|
||||||
|
* (`/jury/finals-documents`) or program admins (`/admin/finals-documents`).
|
||||||
|
*/
|
||||||
|
export function FinalsDocumentsReview() {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user