'use client' import { useState, useCallback, useMemo } from 'react' import Link from 'next/link' import Papa from 'papaparse' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Checkbox } from '@/components/ui/checkbox' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command' import { ArrowLeft, ArrowRight, AlertCircle, CheckCircle2, Loader2, Users, X, Plus, FileSpreadsheet, UserPlus, FolderKanban, ChevronDown, Check, Tags, } from 'lucide-react' import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' interface Assignment { projectId: string roundId: string } interface MemberRow { id: string name: string email: string role: Role expertiseTags: string[] assignments: Assignment[] } interface ParsedUser { email: string name?: string role: Role expertiseTags?: string[] assignments?: Assignment[] isValid: boolean error?: string isDuplicate?: boolean } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const ROLE_LABELS: Record = { PROGRAM_ADMIN: 'Program Admin', JURY_MEMBER: 'Jury Member', MENTOR: 'Mentor', OBSERVER: 'Observer', } let rowIdCounter = 0 function nextRowId(): string { return `row-${++rowIdCounter}` } function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow { return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], assignments: [] } } /** Inline tag picker with grouped dropdown from database tags */ function TagPicker({ selectedTags, onAdd, onRemove, }: { selectedTags: string[] onAdd: (tag: string) => void onRemove: (tag: string) => void }) { const [open, setOpen] = useState(false) const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true }) const tags = data?.tags || [] const tagsByCategory = useMemo(() => { const grouped: Record = {} for (const tag of tags) { const category = tag.category || 'Other' if (!grouped[category]) grouped[category] = [] grouped[category].push(tag) } return grouped }, [tags]) return (
{isLoading ? 'Loading tags...' : 'No tags found.'} {Object.entries(tagsByCategory) .sort(([a], [b]) => a.localeCompare(b)) .map(([category, categoryTags]) => ( {categoryTags.map((tag) => { const isSelected = selectedTags.includes(tag.name) return ( { if (isSelected) { onRemove(tag.name) } else { onAdd(tag.name) } }} >
{isSelected && }
{tag.name} {tag.color && ( )}
) })}
))}
{selectedTags.length > 0 && (
{selectedTags.map((tagName) => { const tagData = tags.find((t) => t.name === tagName) return ( {tagName} ) })}
)}
) } export default function MemberInvitePage() { const [step, setStep] = useState('input') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [rows, setRows] = useState([createEmptyRow()]) const [parsedUsers, setParsedUsers] = useState([]) const [sendProgress, setSendProgress] = useState(0) const [result, setResult] = useState<{ created: number skipped: number assignmentsCreated?: number } | null>(null) // Pre-assignment state const [selectedRoundId, setSelectedRoundId] = useState('') const utils = trpc.useUtils() // Fetch current user to check role const { data: currentUser } = trpc.user.me.useQuery() const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const bulkCreate = trpc.user.bulkCreate.useMutation({ onSuccess: () => { // Invalidate user list to refresh the members table when navigating back utils.user.list.invalidate() }, }) // Fetch programs with rounds for pre-assignment const { data: programsData } = trpc.program.list.useQuery({ status: 'ACTIVE', includeRounds: true, }) // Flatten all rounds from all programs const rounds = useMemo(() => { if (!programsData) return [] type ProgramWithRounds = typeof programsData[number] & { rounds?: Array<{ id: string; name: string }> } return (programsData as ProgramWithRounds[]).flatMap((program) => (program.rounds || []).map((round) => ({ id: round.id, name: round.name, programName: `${program.name} ${program.year}`, })) ) }, [programsData]) // Fetch projects for selected round const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery( { roundId: selectedRoundId, perPage: 200 }, { enabled: !!selectedRoundId } ) const projects = projectsData?.projects || [] // --- Manual entry helpers --- const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => { setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)) ) } const removeRow = (id: string) => { setRows((prev) => { const filtered = prev.filter((r) => r.id !== id) return filtered.length === 0 ? [createEmptyRow()] : filtered }) } const addRow = () => { const lastRole = rows[rows.length - 1]?.role || 'JURY_MEMBER' setRows((prev) => [...prev, createEmptyRow(lastRole)]) } // Per-row tag management const addTagToRow = (id: string, tag: string) => { const trimmed = tag.trim() if (!trimmed) return setRows((prev) => prev.map((r) => { if (r.id !== id) return r if (r.expertiseTags.includes(trimmed)) return r return { ...r, expertiseTags: [...r.expertiseTags, trimmed] } }) ) } const removeTagFromRow = (id: string, tag: string) => { setRows((prev) => prev.map((r) => r.id === id ? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) } : r ) ) } // Per-row project assignment management const toggleProjectAssignment = (rowId: string, projectId: string) => { if (!selectedRoundId) return setRows((prev) => prev.map((r) => { if (r.id !== rowId) return r const existing = r.assignments.find((a) => a.projectId === projectId) if (existing) { return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) } } else { return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] } } }) ) } const clearRowAssignments = (rowId: string) => { setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, assignments: [] } : r)) ) } // --- CSV helpers --- const handleCSVUpload = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return Papa.parse>(file, { header: true, skipEmptyLines: true, complete: (results) => { const seenEmails = new Set() const users: ParsedUser[] = results.data.map((row) => { const emailKey = Object.keys(row).find( (key) => key.toLowerCase() === 'email' || key.toLowerCase().includes('email') ) const nameKey = Object.keys(row).find( (key) => key.toLowerCase() === 'name' || key.toLowerCase().includes('name') ) const roleKey = Object.keys(row).find( (key) => key.toLowerCase() === 'role' || key.toLowerCase().includes('role') ) const email = emailKey ? row[emailKey]?.trim().toLowerCase() : '' const name = nameKey ? row[nameKey]?.trim() : undefined const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : '' const role: Role = rawRole === 'PROGRAM_ADMIN' ? 'PROGRAM_ADMIN' : rawRole === 'MENTOR' ? 'MENTOR' : rawRole === 'OBSERVER' ? 'OBSERVER' : 'JURY_MEMBER' const isValidFormat = emailRegex.test(email) const isDuplicate = email ? seenEmails.has(email) : false const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin if (isValidFormat && !isDuplicate && email) seenEmails.add(email) return { email, name, role, isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin, isDuplicate, error: !email ? 'No email found' : !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : isUnauthorizedAdmin ? 'Only super admins can invite program admins' : undefined, } }) setParsedUsers(users.filter((u) => u.email)) setStep('preview') }, }) }, [isSuperAdmin] ) // --- Parse manual rows into ParsedUser format --- const parseManualRows = (): ParsedUser[] => { const seenEmails = new Set() return rows .filter((r) => r.email.trim()) .map((r) => { const email = r.email.trim().toLowerCase() const isValidFormat = emailRegex.test(email) const isDuplicate = seenEmails.has(email) const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin if (isValidFormat && !isDuplicate) seenEmails.add(email) return { email, name: r.name.trim() || undefined, role: r.role, expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined, assignments: r.assignments.length > 0 ? r.assignments : undefined, isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin, isDuplicate, error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : isUnauthorizedAdmin ? 'Only super admins can invite program admins' : undefined, } }) } const handleManualProceed = () => { const parsed = parseManualRows() if (parsed.length === 0) return setParsedUsers(parsed) setStep('preview') } // --- Summary --- const summary = useMemo(() => { const validUsers = parsedUsers.filter((u) => u.isValid) const invalidUsers = parsedUsers.filter((u) => !u.isValid) return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, validUsers, invalidUsers, } }, [parsedUsers]) const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid)) // --- Send --- const handleSendInvites = async () => { if (summary.valid === 0) return setStep('sending') setSendProgress(0) try { const result = await bulkCreate.mutateAsync({ users: summary.validUsers.map((u) => ({ email: u.email, name: u.name, role: u.role, expertiseTags: u.expertiseTags, assignments: u.assignments, })), }) setSendProgress(100) setResult(result) setStep('complete') } catch { setStep('preview') } } const resetForm = () => { setStep('input') setRows([createEmptyRow()]) setParsedUsers([]) setResult(null) setSendProgress(0) } const hasManualData = rows.some((r) => r.email.trim() || r.name.trim()) const steps: Array<{ key: Step; label: string }> = [ { key: 'input', label: 'Input' }, { key: 'preview', label: 'Preview' }, { key: 'sending', label: 'Send' }, { key: 'complete', label: 'Done' }, ] const currentStepIndex = steps.findIndex((s) => s.key === step) const renderStep = () => { switch (step) { case 'input': return ( Invite Members Add members individually or upload a CSV file {isSuperAdmin && ( As a super admin, you can also invite program admins )} {/* Method toggle */}
{inputMethod === 'manual' ? (
{/* Round selector for pre-assignments */}

Select a round to assign projects to jury members before they onboard

{/* Member cards */} {rows.map((row, index) => (
Member {index + 1}
updateRow(row.id, 'name', e.target.value) } /> updateRow(row.id, 'email', e.target.value) } />
{/* Per-member expertise tags */} addTagToRow(row.id, tag)} onRemove={(tag) => removeTagFromRow(row.id, tag)} /> {/* Per-member project pre-assignment (only for jury members) */} {row.role === 'JURY_MEMBER' && selectedRoundId && ( {projectsLoading ? (
Loading projects...
) : projects.length === 0 ? (

No projects in this round

) : (
{projects.map((project) => { const isAssigned = row.assignments.some( (a) => a.projectId === project.id ) return ( ) })}
)} {row.assignments.length > 0 && ( )}
)}
))}
) : (

CSV should have columns: name, email, role (optional)

document.getElementById('csv-input')?.click() } >

Drop CSV file here or click to browse

)} {/* Actions */}
{inputMethod === 'manual' && ( )}
) case 'preview': return ( Preview Invitations Review the list of members to invite

{summary.total}

Total

{summary.valid}

Valid

{summary.invalid}

Invalid

{summary.invalid > 0 && (

{summary.invalid} email(s) have issues

)}
Name Email Role Status {parsedUsers.map((user, index) => ( {user.name || '-'} {user.email} {ROLE_LABELS[user.role]} {user.isValid ? ( Valid ) : ( {user.error} )} ))}
{bulkCreate.error && (
{bulkCreate.error.message}
)}
) case 'sending': return (

Creating members and sending invitations...

) case 'complete': return (

Invitations Sent!

{result?.created} member{result?.created !== 1 ? 's' : ''}{' '} created and invited. {result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''} {result?.assignmentsCreated && result.assignmentsCreated > 0 ? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.` : ''}

) } } return (

Invite Members

Add new members to the platform

{/* Step indicator */}
{steps.map((s, index) => (
{index > 0 && (
)}
{index + 1}
))}
{renderStep()}
) }