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 { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
|
||||||
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
|
||||||
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
|
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 { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
@@ -1543,6 +1544,7 @@ export default function RoundDetailPage() {
|
|||||||
<FinalDocsReminderButton programId={programId} />
|
<FinalDocsReminderButton programId={programId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ReviewDocsPicker programId={programId} roundId={roundId} />
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FinalistSlotsCard programId={programId} />
|
<FinalistSlotsCard programId={programId} />
|
||||||
<WaitlistCard 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() {
|
export function FinalDocumentsBanner() {
|
||||||
const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery()
|
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 fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
|
||||||
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
|
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
|
||||||
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
|
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
|
||||||
const uploadedCount = status.requirements.filter((r) => r.uploaded).length
|
const uploadedCount = status.requirements.filter((r) => r.uploaded).length
|
||||||
const total = status.requirements.length
|
const total = status.requirements.length
|
||||||
const done = status.allRequiredUploaded
|
const optionalMode = !status.hasRequired
|
||||||
|
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
|
<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">
|
<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" />}
|
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
|
||||||
<span className="font-semibold">
|
<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>
|
||||||
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
|
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export function FinalDocumentsPanel(props: Props) {
|
|||||||
{ enabled: props.variant === 'mentor' },
|
{ enabled: props.variant === 'mentor' },
|
||||||
)
|
)
|
||||||
const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data
|
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' })
|
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
|
||||||
return (
|
return (
|
||||||
@@ -26,8 +27,8 @@ export function FinalDocumentsPanel(props: Props) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<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>
|
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
|
||||||
{status.allRequiredUploaded
|
{done
|
||||||
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">Submitted</Badge>
|
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</Badge>
|
||||||
: status.deadline && (
|
: status.deadline && (
|
||||||
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
|
<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))}
|
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
|
||||||
@@ -36,6 +37,7 @@ export function FinalDocumentsPanel(props: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
|
{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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<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>
|
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
|
||||||
</div>
|
</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">
|
<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>
|
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user