diff --git a/src/components/admin/grand-finale/waitlist-card.tsx b/src/components/admin/grand-finale/waitlist-card.tsx index e41eccc..19aeff3 100644 --- a/src/components/admin/grand-finale/waitlist-card.tsx +++ b/src/components/admin/grand-finale/waitlist-card.tsx @@ -1,5 +1,6 @@ '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' @@ -17,7 +18,14 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { ListOrdered, Loader2 } from 'lucide-react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { ListOrdered, Loader2, PlusCircle } from 'lucide-react' import { formatEnumLabel } from '@/lib/utils' import type { CompetitionCategory } from '@prisma/client' @@ -25,6 +33,145 @@ interface Props { programId: string } +function AddToWaitlistForm({ programId }: { programId: string }) { + const utils = trpc.useUtils() + const [category, setCategory] = useState('') + const [projectId, setProjectId] = useState('') + + const { data: candidatesData, isLoading: loadingCandidates } = + trpc.finalist.listEnrollmentCandidates.useQuery({ programId }) + + const { data: waitlistData } = trpc.finalist.listWaitlist.useQuery({ programId }) + + const addMutation = trpc.finalist.addToWaitlist.useMutation({ + onSuccess: () => { + toast.success('Project added to waitlist') + utils.finalist.listWaitlist.invalidate({ programId }) + utils.finalist.listEnrollmentCandidates.invalidate({ programId }) + setProjectId('') + }, + onError: (err) => toast.error(err.message), + }) + + // Build set of project IDs already on the waitlist + const waitlistedProjectIds = new Set( + (waitlistData ?? []) + .filter((e) => e.status === 'WAITING' || e.status === 'PROMOTED') + .map((e) => e.projectId), + ) + + // Candidates per selected category — exclude confirmed/waitlisted + const categoryData = candidatesData?.categories.find((c) => c.category === category) + const availableCandidates = (categoryData?.candidates ?? []).filter( + (c) => + !waitlistedProjectIds.has(c.projectId) && + c.confirmationStatus !== 'CONFIRMED', + ) + + // Category options (only categories that have candidates) + const categoryOptions = (candidatesData?.categories ?? []).filter( + (c) => + c.candidates.some( + (p) => + !waitlistedProjectIds.has(p.projectId) && + p.confirmationStatus !== 'CONFIRMED', + ), + ) + + // Derive the next rank for the selected category + const currentMaxRank = Math.max( + 0, + ...(waitlistData ?? []) + .filter((e) => e.category === category) + .map((e) => e.rank), + ) + const nextRank = currentMaxRank + 1 + + const canSubmit = !!category && !!projectId && !addMutation.isPending + + if (loadingCandidates) return + + return ( +
+
+ + Add to waitlist +
+
+
+ +
+
+ +
+ +
+
+ ) +} + const STATUS_LABEL: Record = { WAITING: { label: 'Waiting', variant: 'outline' }, PROMOTED: { label: 'Promoted', variant: 'default' }, @@ -65,10 +212,11 @@ export function WaitlistCard({ programId }: Props) { Per-category ranked waitlist. Auto-cascades when a finalist declines or expires. - -

+ +

No waitlist entries yet.

+
) @@ -161,6 +309,7 @@ export function WaitlistCard({ programId }: Props) { ))} + ) diff --git a/src/components/admin/logistics/confirmations-tab.tsx b/src/components/admin/logistics/confirmations-tab.tsx index 7ecf643..f0bc47b 100644 --- a/src/components/admin/logistics/confirmations-tab.tsx +++ b/src/components/admin/logistics/confirmations-tab.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' @@ -14,6 +15,19 @@ import { TableRow, } from '@/components/ui/table' import { Button } from '@/components/ui/button' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Textarea } from '@/components/ui/textarea' +import { Loader2 } from 'lucide-react' import { formatEnumLabel } from '@/lib/utils' import type { FinalistConfirmationStatus } from '@prisma/client' import { AdminAttendanceDialog, type AttendanceMode } from './admin-attendance-dialog' @@ -52,17 +66,52 @@ function relativeFromNow(d: Date): string { } export function ConfirmationsTab({ programId }: Props) { + const utils = trpc.useUtils() const [statusFilter, setStatusFilter] = useState('all') const [dialogState, setDialogState] = useState<{ open: boolean mode: AttendanceMode confirmationId: string | null }>({ open: false, mode: 'confirm', confirmationId: null }) + const [unconfirmState, setUnconfirmState] = useState<{ + open: boolean + confirmationId: string | null + projectTitle: string + reason: string + }>({ open: false, confirmationId: null, projectTitle: '', reason: '' }) + const { data, isLoading } = trpc.logistics.listConfirmations.useQuery( { programId }, { refetchInterval: 60_000 }, ) + // Get liveFinalRoundId for re-invite action + const { data: candidatesData } = trpc.finalist.listEnrollmentCandidates.useQuery({ programId }) + const liveFinalRoundId = candidatesData?.liveFinalRoundId ?? null + + const unconfirmMutation = trpc.finalist.unconfirm.useMutation({ + onSuccess: () => { + toast.success('Finalist un-confirmed') + utils.logistics.listConfirmations.invalidate({ programId }) + utils.finalist.listEnrollmentCandidates.invalidate({ programId }) + setUnconfirmState((prev) => ({ ...prev, open: false, confirmationId: null, reason: '' })) + }, + onError: (err) => toast.error(err.message), + }) + + const reinviteMutation = trpc.finalist.enrollFinalists.useMutation({ + onSuccess: (result) => { + if (result.skipped.length > 0) { + toast.info('Re-invite skipped — team is already confirmed') + } else { + toast.success('Re-invite sent') + } + utils.logistics.listConfirmations.invalidate({ programId }) + utils.finalist.listEnrollmentCandidates.invalidate({ programId }) + }, + onError: (err) => toast.error(err.message), + }) + const filtered = useMemo(() => { if (!data) return [] return statusFilter === 'all' ? data : data.filter((r) => r.status === statusFilter) @@ -124,7 +173,7 @@ export function ConfirmationsTab({ programId }: Props) { ) : filtered.length === 0 ? (

{statusFilter === 'all' - ? 'No finalists have been selected yet. Use the grand-finale round page to send confirmations.' + ? 'No finalists have been selected yet. Enroll finalists from the Grand Final round\'s Overview tab to start confirmations.' : 'No confirmations match this filter.'}

) : ( @@ -218,6 +267,43 @@ export function ConfirmationsTab({ programId }: Props) { Decline + ) : r.status === 'CONFIRMED' ? ( + + ) : r.status === 'DECLINED' || r.status === 'EXPIRED' ? ( + ) : ( )} @@ -241,6 +327,60 @@ export function ConfirmationsTab({ programId }: Props) { setDialogState((prev) => ({ ...prev, open: next })) } /> + + {/* Un-confirm AlertDialog (needs a reason — min 5 chars per the server) */} + { + if (!unconfirmMutation.isPending) + setUnconfirmState((prev) => ({ ...prev, open: next })) + }} + > + + + Un-confirm this finalist? + + {unconfirmState.projectTitle} will be moved back to Superseded. Any active mentor + assignment will be dropped and the mentor notified. This action is audit-logged. + + +
+ +