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:
@@ -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 */}
|
||||||
|
|||||||
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal file
159
src/components/admin/grand-finale/enroll-attendees-dialog.tsx
Normal 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
|
||||||
|
"Visa?" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal file
456
src/components/admin/grand-finale/finalist-enrollment-card.tsx
Normal 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'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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user