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:
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