'use client' import { useState, useCallback, useEffect, useMemo } from 'react' import Link from 'next/link' import { useSearchParams, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Checkbox } from '@/components/ui/checkbox' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Skeleton } from '@/components/ui/skeleton' import { UserAvatar } from '@/components/shared/user-avatar' import { UserActions, UserMobileActions } from '@/components/admin/user-actions' import { Pagination } from '@/components/shared/pagination' import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react' import { toast } from 'sonner' import { formatRelativeTime } from '@/lib/utils' import { AnimatePresence, motion } from 'motion/react' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' | 'applicants' const TAB_ROLES: Record = { all: undefined, jury: ['JURY_MEMBER'], mentors: ['MENTOR'], observers: ['OBSERVER'], admins: ['SUPER_ADMIN', 'PROGRAM_ADMIN'], applicants: undefined, // handled separately } const statusColors: Record = { NONE: 'secondary', ACTIVE: 'success', INVITED: 'secondary', SUSPENDED: 'destructive', } const statusLabels: Record = { NONE: 'Not Invited', INVITED: 'Invited', ACTIVE: 'Active', SUSPENDED: 'Suspended', } const roleColors: Record = { JURY_MEMBER: 'default', MENTOR: 'secondary', OBSERVER: 'outline', PROGRAM_ADMIN: 'default', SUPER_ADMIN: 'destructive' as 'default', } function InlineSendInvite({ userId, userEmail }: { userId: string; userEmail: string }) { const utils = trpc.useUtils() const sendInvitation = trpc.user.sendInvitation.useMutation({ onSuccess: () => { toast.success(`Invitation sent to ${userEmail}`) utils.user.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to send invitation') }, }) return ( ) } export function MembersContent() { const searchParams = useSearchParams() const pathname = usePathname() const tab = (searchParams.get('tab') as TabKey) || 'all' const search = searchParams.get('search') || '' const page = parseInt(searchParams.get('page') || '1', 10) const [searchInput, setSearchInput] = useState(search) // Debounced search useEffect(() => { const timer = setTimeout(() => { updateParams({ search: searchInput || null, page: '1' }) }, 300) return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchInput]) const updateParams = useCallback( (updates: Record) => { const params = new URLSearchParams(searchParams.toString()) Object.entries(updates).forEach(([key, value]) => { if (value === null || value === '') { params.delete(key) } else { params.set(key, value) } }) window.history.replaceState(null, '', `${pathname}?${params.toString()}`) }, [searchParams, pathname] ) const roles = TAB_ROLES[tab] const [selectedIds, setSelectedIds] = useState>(new Set()) const { data: currentUser } = trpc.user.me.useQuery() const currentUserRole = currentUser?.role as RoleValue | undefined const { data, isLoading } = trpc.user.list.useQuery({ roles: roles, search: search || undefined, page, perPage: 20, }) const invitableIdsQuery = trpc.user.listInvitableIds.useQuery( { roles: roles, search: search || undefined, }, { enabled: false } ) const utils = trpc.useUtils() const bulkInvite = trpc.user.bulkSendInvitations.useMutation({ onSuccess: (result) => { const { sent, errors } = result as { sent: number; skipped: number; errors: string[] } if (errors && errors.length > 0) { toast.warning(`Sent ${sent} invitation${sent !== 1 ? 's' : ''}, ${errors.length} failed`) } else { toast.success(`Invitations sent to ${sent} member${sent !== 1 ? 's' : ''}`) } setSelectedIds(new Set()) utils.user.list.invalidate() }, onError: (error) => { toast.error(error.message || 'Failed to send invitations') }, }) const selectableUsers = useMemo( () => data?.users ?? [], [data?.users] ) const allSelectableSelected = selectableUsers.length > 0 && selectableUsers.every((u) => selectedIds.has(u.id)) const someSelectableSelected = selectableUsers.some((u) => selectedIds.has(u.id)) && !allSelectableSelected const toggleUser = useCallback((userId: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(userId)) { next.delete(userId) } else { next.add(userId) } return next }) }, []) const toggleAll = useCallback(() => { if (allSelectableSelected) { // Deselect all on this page setSelectedIds((prev) => { const next = new Set(prev) for (const u of selectableUsers) { next.delete(u.id) } return next }) } else { // Select all selectable on this page setSelectedIds((prev) => { const next = new Set(prev) for (const u of selectableUsers) { next.add(u.id) } return next }) } }, [allSelectableSelected, selectableUsers]) const selectAllMatching = useCallback(async () => { const result = await invitableIdsQuery.refetch() const ids = result.data?.userIds ?? [] if (ids.length === 0) { toast.info('No invitable members match the current filter') return } setSelectedIds(new Set(ids)) toast.success(`Selected ${ids.length} matching members`) }, [invitableIdsQuery]) const clearSelection = useCallback(() => { setSelectedIds(new Set()) }, []) const handleTabChange = (value: string) => { updateParams({ tab: value === 'all' ? null : value, page: '1' }) } return (
{/* Header */}

Members

Manage jury members, mentors, observers, and admins

{/* Tabs */}
All Jury Mentors Observers Admins Applicants {/* Search */}
setSearchInput(e.target.value)} className="pl-9" />
{/* Applicants tab */} {tab === 'applicants' && } {/* Content (non-applicant tabs) */} {tab !== 'applicants' && isLoading ? ( ) : data && data.users.length > 0 ? ( <> {/* Bulk selection controls */}

Selection persists across pages and filters.

{/* Desktop table */} {selectableUsers.length > 0 && ( )} Member Role Expertise Assignments Status Last Login Actions {data.users.map((user) => ( toggleUser(user.id)} aria-label={`Select ${user.name || user.email}`} />
).avatarUrl as string | undefined} size="sm" />

{user.name || 'Unnamed'}

{user.email}

{((user as unknown as { roles?: string[] }).roles?.length ? (user as unknown as { roles: string[] }).roles : [user.role] ).map((r) => ( {r.replace(/_/g, ' ')} ))}
{user.expertiseTags && user.expertiseTags.length > 0 ? (
{user.expertiseTags.slice(0, 2).map((tag) => ( {tag} ))} {user.expertiseTags.length > 2 && ( +{user.expertiseTags.length - 2} )}
) : ( - )}
{user.role === 'MENTOR' ? (

{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored

) : (

{(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned

)}
{statusLabels[user.status] || user.status} {user.status === 'NONE' && ( )}
{user.lastLoginAt ? ( {formatRelativeTime(user.lastLoginAt)} ) : ( Never )}
))}
{/* Mobile cards */}
{data.users.map((user) => (
toggleUser(user.id)} aria-label={`Select ${user.name || user.email}`} className="mt-1" /> ).avatarUrl as string | undefined} size="md" />
{user.name || 'Unnamed'} {user.email}
{statusLabels[user.status] || user.status} {user.status === 'NONE' && ( )}
Role
{((user as unknown as { roles?: string[] }).roles?.length ? (user as unknown as { roles: string[] }).roles : [user.role] ).map((r) => ( {r.replace(/_/g, ' ')} ))}
Assignments {user.role === 'MENTOR' ? `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.mentorAssignments} mentored` : `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned`}
Last Login {user.lastLoginAt ? ( formatRelativeTime(user.lastLoginAt) ) : ( Never )}
{user.expertiseTags && user.expertiseTags.length > 0 && (
{user.expertiseTags.map((tag) => ( {tag} ))}
)}
))}
{/* Pagination */} updateParams({ page: String(newPage) })} /> ) : tab !== 'applicants' ? (

No members found

{search ? 'Try adjusting your search' : 'Invite members to get started'}

) : null} {/* Floating bulk invite toolbar */} {selectedIds.size > 0 && ( {selectedIds.size} selected )}
) } function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: string; searchInput: string; setSearchInput: (v: string) => void }) { const [page, setPage] = useState(1) const [selectedIds, setSelectedIds] = useState>(new Set()) const utils = trpc.useUtils() const { data, isLoading } = trpc.user.getApplicants.useQuery({ search: search || undefined, page, perPage: 20, }) const bulkInvite = trpc.user.bulkInviteApplicants.useMutation({ onSuccess: (result) => { const msg = `Sent ${result.sent} invite${result.sent !== 1 ? 's' : ''}` if (result.failed.length > 0) { toast.warning(`${msg}, ${result.failed.length} failed`) } else { toast.success(msg) } setSelectedIds(new Set()) utils.user.getApplicants.invalidate() }, onError: (error) => toast.error(error.message), }) const toggleUser = useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, []) const users = data?.users ?? [] const allSelected = users.length > 0 && users.every((u) => selectedIds.has(u.id)) const someSelected = users.some((u) => selectedIds.has(u.id)) && !allSelected const toggleAll = useCallback(() => { if (allSelected) { setSelectedIds((prev) => { const next = new Set(prev) users.forEach((u) => next.delete(u.id)) return next }) } else { setSelectedIds((prev) => { const next = new Set(prev) users.forEach((u) => next.add(u.id)) return next }) } }, [allSelected, users]) if (isLoading) return if (!data || data.users.length === 0) { return (

No applicants found

{search ? 'Try adjusting your search' : 'Applicant users will appear here after CSV import or project submission'}

) } return ( <> {/* Desktop table */} Applicant Project Status Last Login {data.users.map((user) => ( toggleUser(user.id)} />

{user.name || 'Unnamed'}

{user.email}

{user.projectName ? ( {user.projectName} ) : ( - )}
{statusLabels[user.status] || user.status} {user.status === 'NONE' && ( )}
{user.lastLoginAt ? ( {formatRelativeTime(user.lastLoginAt)} ) : ( Never )}
))}
{/* Mobile cards */}
{data.users.map((user) => (
toggleUser(user.id)} className="mt-1" />

{user.name || 'Unnamed'}

{user.email}

{user.projectName && (

{user.projectName}

)}
{statusLabels[user.status] || user.status}
))}
{data.totalPages > 1 && ( )} {/* Floating bulk invite bar */} {selectedIds.size > 0 && ( {selectedIds.size} selected )} ) } function MembersSkeleton() { return (
{[...Array(5)].map((_, i) => (
))}
) }