feat(final-docs): optional-mode banner/panel + admin 'Documents shown to judges' picker
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m31s

- finalist banner/panel render an optional-uploads state (settle to green via
  hasRequired ? allRequiredUploaded : allUploaded; 'optional' copy when nothing required)
- ReviewDocsPicker admin card on the LIVE_FINAL round page to curate judge-visible docs
This commit is contained in:
Matt
2026-06-10 15:02:19 +02:00
parent 28ca7bb0a6
commit d38fe7887a
4 changed files with 93 additions and 7 deletions

View File

@@ -98,6 +98,7 @@ import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -1543,6 +1544,7 @@ export default function RoundDetailPage() {
<FinalDocsReminderButton programId={programId} />
</div>
</div>
<ReviewDocsPicker programId={programId} roundId={roundId} />
<div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} />

View File

@@ -0,0 +1,79 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -8,14 +8,15 @@ import { FileText, Video, CheckCircle2, Circle, Clock, Upload } from 'lucide-rea
export function FinalDocumentsBanner() {
const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery()
if (!status) return null
if (!status || status.requirements.length === 0) return null
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
const uploadedCount = status.requirements.filter((r) => r.uploaded).length
const total = status.requirements.length
const done = status.allRequiredUploaded
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
return (
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
@@ -24,7 +25,9 @@ export function FinalDocumentsBanner() {
<div className="flex items-center gap-2">
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
<span className="font-semibold">
{done ? 'Grand Final documents submitted' : 'Upload your Grand Final documents'}
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
</div>

View File

@@ -18,7 +18,8 @@ export function FinalDocumentsPanel(props: Props) {
{ enabled: props.variant === 'mentor' },
)
const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data
if (!status) return null
if (!status || status.requirements.length === 0) return null
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
return (
@@ -26,8 +27,8 @@ export function FinalDocumentsPanel(props: Props) {
<CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
{status.allRequiredUploaded
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">Submitted</Badge>
{done
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</Badge>
: status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
@@ -36,6 +37,7 @@ export function FinalDocumentsPanel(props: Props) {
</div>
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
@@ -48,7 +50,7 @@ export function FinalDocumentsPanel(props: Props) {
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
</div>
))}
{props.variant === 'team' && !status.allRequiredUploaded && (
{props.variant === 'team' && !done && (
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
</Button>