feat(grand-finale): finalist enrollment card on LIVE_FINAL round page

Adds EnrollAttendeesDialog and FinalistEnrollmentCard components and
wires the card above FinalistSlotsCard on the LIVE_FINAL round Overview,
giving admins the missing UI entry point to enroll mentoring-round teams
into the Grand Final via EMAIL or ADMIN_CONFIRM mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-04 15:32:40 +02:00
parent e80710487c
commit 2a98f0cacf
3 changed files with 623 additions and 4 deletions

View File

@@ -95,6 +95,7 @@ import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
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'
@@ -1526,10 +1527,13 @@ export default function RoundDetailPage() {
{/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */} {/* Grand-finale logistics \u2014 only on LIVE_FINAL rounds */}
{isGrandFinale && programId && ( {isGrandFinale && programId && (
<>
<FinalistEnrollmentCard 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} />
</div> </div>
</>
)} )}
{/* Round Info + Project Breakdown */} {/* Round Info + Project Breakdown */}

View File

@@ -0,0 +1,159 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
export type AttendeeSelection = {
attendingUserIds: string[]
visaFlags: Record<string, boolean>
}
type Member = {
userId: string
name: string | null
role: string
email: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
members: Member[]
cap: number
onConfirm: (attendingUserIds: string[], visaFlags: Record<string, boolean>) => void
initial?: AttendeeSelection
isPending?: boolean
}
export function EnrollAttendeesDialog({
open,
onOpenChange,
members,
cap,
onConfirm,
initial,
isPending = false,
}: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [visa, setVisa] = useState<Record<string, boolean>>({})
// Seed from initial or default to first member (the lead)
useEffect(() => {
if (!open) return
if (initial) {
setSelected(new Set(initial.attendingUserIds))
setVisa(initial.visaFlags)
} else {
const defaultSelected = members.slice(0, 1).map((m) => m.userId)
setSelected(new Set(defaultSelected))
setVisa({})
}
}, [open, initial, members])
const overCap = selected.size > cap
const noneSelected = selected.size === 0
const toggleMember = (userId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(userId)
else next.delete(userId)
return next
})
}
const handleConfirm = () => {
const ids = Array.from(selected)
onConfirm(
ids,
Object.fromEntries(ids.map((id) => [id, !!visa[id]])),
)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isPending) onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select attendees</DialogTitle>
<DialogDescription>
Choose up to {cap} team member{cap === 1 ? '' : 's'} who will attend. Toggle
&quot;Visa?&quot; for anyone who needs a visa letter.
</DialogDescription>
</DialogHeader>
<ul className="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
{members.map((m) => {
const checked = selected.has(m.userId)
const atCap = !checked && selected.size >= cap
return (
<li
key={m.userId}
className="flex items-start justify-between gap-4 rounded-md border px-3 py-2"
>
<label className="flex flex-1 cursor-pointer items-start gap-3">
<Checkbox
checked={checked}
disabled={atCap}
onCheckedChange={(c) => toggleMember(m.userId, c === true)}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium">{m.name ?? m.email}</div>
<div className="text-muted-foreground text-xs">
{m.email}
{m.role && m.role !== 'MEMBER' ? ` · ${m.role.toLowerCase()}` : ''}
</div>
</div>
</label>
{checked && (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Visa?</span>
<Switch
checked={!!visa[m.userId]}
onCheckedChange={(c) => setVisa((prev) => ({ ...prev, [m.userId]: c }))}
/>
</label>
)}
</li>
)
})}
</ul>
{overCap && (
<p className="text-destructive text-sm">
Please select no more than {cap} member{cap === 1 ? '' : 's'}.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={overCap || noneSelected || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm attendees
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,456 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, UserCheck } from 'lucide-react'
import { formatEnumLabel } from '@/lib/utils'
import {
EnrollAttendeesDialog,
type AttendeeSelection,
} from './enroll-attendees-dialog'
interface Props {
programId: string
roundId: string
}
type EnrollMode = 'EMAIL' | 'ADMIN_CONFIRM'
type RowState = {
mode: EnrollMode
attendees?: AttendeeSelection
}
type Candidate = {
projectId: string
title: string
teamName: string | null
country: string | null
inLiveFinal: boolean
confirmationStatus: string | null
teamMembers: Array<{ userId: string; name: string | null; role: string; email: string }>
}
const STATUS_CONFIG: Record<
string,
{ label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }
> = {
PENDING: { label: 'Pending', variant: 'secondary' },
CONFIRMED: { label: 'Confirmed', variant: 'default' },
DECLINED: { label: 'Declined', variant: 'destructive' },
EXPIRED: { label: 'Expired', variant: 'outline' },
}
function deriveStatus(candidate: Candidate): string {
if (candidate.confirmationStatus) return candidate.confirmationStatus
if (candidate.inLiveFinal) return 'IN_ROUND'
return 'NOT_ENROLLED'
}
function StatusBadge({ candidate }: { candidate: Candidate }) {
const status = deriveStatus(candidate)
if (status === 'NOT_ENROLLED') {
return (
<Badge variant="outline" className="text-xs">
Not enrolled
</Badge>
)
}
if (status === 'IN_ROUND') {
return (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)
}
const cfg = STATUS_CONFIG[status] ?? { label: status, variant: 'outline' as const }
return (
<Badge variant={cfg.variant} className="text-xs">
{cfg.label}
</Badge>
)
}
export function FinalistEnrollmentCard({ programId, roundId }: Props) {
const utils = trpc.useUtils()
const { data, isLoading } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId })
// Per-row selection + mode state
const [selected, setSelected] = useState<Set<string>>(new Set())
const [rowState, setRowState] = useState<Record<string, RowState>>({})
// Dialog state for "Set attendees now" picker
const [attendeesDialog, setAttendeesDialog] = useState<{
open: boolean
projectId: string
members: Candidate['teamMembers']
} | null>(null)
// Un-enroll state
const [unenrolling, setUnenrolling] = useState<string | null>(null)
const invalidateQueries = () => {
utils.finalist.listEnrollmentCandidates.invalidate({ programId })
utils.logistics.listConfirmations.invalidate({ programId })
}
const enrollMutation = trpc.finalist.enrollFinalists.useMutation({
onSuccess: (result) => {
const parts: string[] = []
if (result.enrolled > 0) parts.push(`${result.enrolled} enrolled`)
if (result.emailed > 0) parts.push(`${result.emailed} emailed`)
if (result.adminConfirmed > 0) parts.push(`${result.adminConfirmed} admin-confirmed`)
if (result.skipped.length > 0) parts.push(`${result.skipped.length} skipped`)
toast.success(parts.join(' · ') || 'Done')
setSelected(new Set())
setRowState({})
invalidateQueries()
},
onError: (err) => toast.error(err.message),
})
const unenrollMutation = trpc.finalist.unenroll.useMutation({
onSuccess: () => {
toast.success('Team removed from the Grand Final round')
setUnenrolling(null)
invalidateQueries()
},
onError: (err) => {
toast.error(err.message)
setUnenrolling(null)
},
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const toggleRow = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
setRowState((prev) => {
if (prev[projectId]) return prev
return { ...prev, [projectId]: { mode: 'EMAIL' } }
})
}
const setMode = (projectId: string, mode: EnrollMode, candidate: Candidate) => {
if (mode === 'ADMIN_CONFIRM') {
setAttendeesDialog({
open: true,
projectId,
members: candidate.teamMembers,
})
} else {
setRowState((prev) => ({
...prev,
[projectId]: { mode: 'EMAIL' },
}))
}
}
const handleAttendeesConfirm = (
projectId: string,
attendingUserIds: string[],
visaFlags: Record<string, boolean>,
) => {
setRowState((prev) => ({
...prev,
[projectId]: {
mode: 'ADMIN_CONFIRM',
attendees: { attendingUserIds, visaFlags },
},
}))
setAttendeesDialog(null)
}
const buildEnrollments = (projectIds: string[]) => {
return projectIds.map((projectId) => {
const rs = rowState[projectId] ?? { mode: 'EMAIL' as EnrollMode }
if (rs.mode === 'ADMIN_CONFIRM' && rs.attendees) {
return {
projectId,
mode: 'ADMIN_CONFIRM' as const,
attendingUserIds: rs.attendees.attendingUserIds,
visaFlags: rs.attendees.visaFlags,
}
}
return { projectId, mode: 'EMAIL' as const }
})
}
const handleEnrollSelected = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const ids = Array.from(selected)
if (ids.length === 0) return
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: buildEnrollments(ids),
})
}
const handleEnrollAllEligible = () => {
if (!data?.liveFinalRoundId) {
toast.error('No LIVE_FINAL round found for this program')
return
}
const allCandidates = data.categories.flatMap((c) => c.candidates)
const eligible = allCandidates.filter((c) => c.confirmationStatus !== 'CONFIRMED')
if (eligible.length === 0) {
toast.info('No eligible teams to enroll')
return
}
enrollMutation.mutate({
programId,
roundId: data.liveFinalRoundId,
enrollments: eligible.map((c) => ({
projectId: c.projectId,
mode: 'EMAIL' as const,
})),
})
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) {
return <Skeleton className="h-56 w-full rounded-md" />
}
const noMentoringTeams = !data || data.categories.length === 0
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<UserCheck className="text-muted-foreground h-4 w-4" />
<CardTitle className="text-base">Enroll finalists</CardTitle>
</div>
<CardDescription>
Select mentoring-round teams to advance into the Grand Final. Each enrolled team
immediately appears on the Finals jury&#39;s project list and receives an attendance
confirmation request (or can be admin-confirmed on the spot).
</CardDescription>
</CardHeader>
<CardContent>
{noMentoringTeams ? (
<p className="text-muted-foreground py-6 text-center text-sm">
No mentoring-round teams to enroll yet.
</p>
) : (
<div className="space-y-6">
{data.categories.map((cat) => (
<div key={cat.category}>
{/* Category header */}
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide">
<span>{formatEnumLabel(cat.category)}</span>
<span className="font-normal">
{cat.confirmedCount}/{cat.quota ?? '?'} confirmed
{cat.pendingCount > 0 ? `, ${cat.pendingCount} pending` : ''}
</span>
</div>
<div className="space-y-2">
{cat.candidates.map((candidate) => {
const status = deriveStatus(candidate)
const isEnrolled =
status === 'CONFIRMED' || status === 'DECLINED' || status === 'EXPIRED'
const isChecked = selected.has(candidate.projectId)
const rs = rowState[candidate.projectId]
const isUnenrolling =
unenrollMutation.isPending && unenrolling === candidate.projectId
return (
<div
key={candidate.projectId}
className="flex flex-wrap items-start gap-3 rounded-md border p-3"
>
{/* Left: checkbox (or spacer for enrolled rows) */}
<div className="mt-0.5 flex-shrink-0">
{isEnrolled ? (
<div className="h-4 w-4" />
) : (
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleRow(candidate.projectId)}
/>
)}
</div>
{/* Middle: project info */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{candidate.title}</span>
<StatusBadge candidate={candidate} />
</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{[candidate.teamName, candidate.country]
.filter(Boolean)
.join(' · ') || '—'}
</div>
{/* Mode toggle for selected rows */}
{isChecked && !isEnrolled && (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant={rs?.mode === 'EMAIL' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() => setMode(candidate.projectId, 'EMAIL', candidate)}
>
Email team
</Button>
<Button
size="sm"
variant={rs?.mode === 'ADMIN_CONFIRM' ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() =>
setMode(candidate.projectId, 'ADMIN_CONFIRM', candidate)
}
>
Set attendees now
{rs?.mode === 'ADMIN_CONFIRM' && rs.attendees && (
<span className="ml-1 opacity-70">
({rs.attendees.attendingUserIds.length})
</span>
)}
</Button>
</div>
)}
</div>
{/* Right: un-enroll button for CONFIRMED/DECLINED rows */}
{(status === 'CONFIRMED' || status === 'DECLINED') && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
disabled={isUnenrolling}
onClick={() => setUnenrolling(candidate.projectId)}
>
{isUnenrolling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Un-enroll'
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove from Grand Final?</AlertDialogTitle>
<AlertDialogDescription>
This removes <strong>{candidate.title}</strong> from the Grand
Final round and deletes their attendance record. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setUnenrolling(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
unenrollMutation.mutate({
projectId: candidate.projectId,
roundId,
})
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
</div>
</div>
))}
{/* Footer actions */}
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
<Button
size="sm"
disabled={selected.size === 0 || enrollMutation.isPending}
onClick={handleEnrollSelected}
>
{enrollMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Enroll selected ({selected.size})
</Button>
<Button
size="sm"
variant="outline"
disabled={enrollMutation.isPending}
onClick={handleEnrollAllEligible}
>
Enroll all eligible
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Attendees picker dialog */}
{attendeesDialog && (
<EnrollAttendeesDialog
open={attendeesDialog.open}
onOpenChange={(open) => {
if (!open) {
// If user closes without confirming, revert mode to EMAIL
setRowState((prev) => ({
...prev,
[attendeesDialog.projectId]: {
mode: 'EMAIL',
},
}))
setAttendeesDialog(null)
}
}}
members={attendeesDialog.members}
cap={data?.attendeeCap ?? 3}
initial={rowState[attendeesDialog.projectId]?.attendees}
onConfirm={(ids, flags) =>
handleAttendeesConfirm(attendeesDialog.projectId, ids, flags)
}
/>
)}
</>
)
}