diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index b1926bc..b28ad03 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -95,6 +95,7 @@ import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-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 { CoverageReport } from '@/components/admin/assignment/coverage-report' 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 */} {isGrandFinale && programId && ( -
- - -
+ <> + +
+ + +
+ )} {/* Round Info + Project Breakdown */} diff --git a/src/components/admin/grand-finale/enroll-attendees-dialog.tsx b/src/components/admin/grand-finale/enroll-attendees-dialog.tsx new file mode 100644 index 0000000..ef1eda5 --- /dev/null +++ b/src/components/admin/grand-finale/enroll-attendees-dialog.tsx @@ -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 +} + +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) => void + initial?: AttendeeSelection + isPending?: boolean +} + +export function EnrollAttendeesDialog({ + open, + onOpenChange, + members, + cap, + onConfirm, + initial, + isPending = false, +}: Props) { + const [selected, setSelected] = useState>(new Set()) + const [visa, setVisa] = useState>({}) + + // 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 ( + { + if (!isPending) onOpenChange(next) + }} + > + + + Select attendees + + Choose up to {cap} team member{cap === 1 ? '' : 's'} who will attend. Toggle + "Visa?" for anyone who needs a visa letter. + + + +
    + {members.map((m) => { + const checked = selected.has(m.userId) + const atCap = !checked && selected.size >= cap + return ( +
  • + + {checked && ( + + )} +
  • + ) + })} +
+ + {overCap && ( +

+ Please select no more than {cap} member{cap === 1 ? '' : 's'}. +

+ )} + + + + + +
+
+ ) +} diff --git a/src/components/admin/grand-finale/finalist-enrollment-card.tsx b/src/components/admin/grand-finale/finalist-enrollment-card.tsx new file mode 100644 index 0000000..e0b4a8d --- /dev/null +++ b/src/components/admin/grand-finale/finalist-enrollment-card.tsx @@ -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 ( + + Not enrolled + + ) + } + if (status === 'IN_ROUND') { + return ( + + In round + + ) + } + const cfg = STATUS_CONFIG[status] ?? { label: status, variant: 'outline' as const } + return ( + + {cfg.label} + + ) +} + +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>(new Set()) + const [rowState, setRowState] = useState>({}) + + // 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(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, + ) => { + 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 + } + + const noMentoringTeams = !data || data.categories.length === 0 + + return ( + <> + + +
+ + Enroll finalists +
+ + Select mentoring-round teams to advance into the Grand Final. Each enrolled team + immediately appears on the Finals jury's project list and receives an attendance + confirmation request (or can be admin-confirmed on the spot). + +
+ + + {noMentoringTeams ? ( +

+ No mentoring-round teams to enroll yet. +

+ ) : ( +
+ {data.categories.map((cat) => ( +
+ {/* Category header */} +
+ {formatEnumLabel(cat.category)} + + — {cat.confirmedCount}/{cat.quota ?? '?'} confirmed + {cat.pendingCount > 0 ? `, ${cat.pendingCount} pending` : ''} + +
+ +
+ {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 ( +
+ {/* Left: checkbox (or spacer for enrolled rows) */} +
+ {isEnrolled ? ( +
+ ) : ( + toggleRow(candidate.projectId)} + /> + )} +
+ + {/* Middle: project info */} +
+
+ {candidate.title} + +
+
+ {[candidate.teamName, candidate.country] + .filter(Boolean) + .join(' · ') || '—'} +
+ + {/* Mode toggle for selected rows */} + {isChecked && !isEnrolled && ( +
+ + +
+ )} +
+ + {/* Right: un-enroll button for CONFIRMED/DECLINED rows */} + {(status === 'CONFIRMED' || status === 'DECLINED') && ( + + + + + + + Remove from Grand Final? + + This removes {candidate.title} from the Grand + Final round and deletes their attendance record. Continue? + + + + setUnenrolling(null)}> + Cancel + + { + unenrollMutation.mutate({ + projectId: candidate.projectId, + roundId, + }) + }} + > + Remove + + + + + )} +
+ ) + })} +
+
+ ))} + + {/* Footer actions */} +
+ + +
+
+ )} + + + + {/* Attendees picker dialog */} + {attendeesDialog && ( + { + 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) + } + /> + )} + + ) +}