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
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:
@@ -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} />
|
||||
|
||||
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal file
79
src/components/admin/grand-finale/review-docs-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user