'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 { ArrowLeft, ArrowRight, AlertCircle, CheckCircle2, Loader2, Users, X, Plus, FileSpreadsheet, UserPlus, FolderKanban, ChevronDown, } from 'lucide-react' import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' interface Assignment { projectId: string roundId: string } interface MemberRow { id: string name: string email: string role: Role expertiseTags: string[] tagInput: 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 = { 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: [], tagInput: '', assignments: [] } } // Common expertise tags for suggestions const SUGGESTED_TAGS = [ 'Marine Biology', 'Ocean Conservation', 'Coral Reef Restoration', 'Sustainable Fisheries', 'Marine Policy', 'Ocean Technology', 'Climate Science', 'Biodiversity', 'Blue Economy', 'Coastal Management', 'Oceanography', 'Marine Pollution', 'Plastic Reduction', 'Renewable Energy', 'Business Development', 'Impact Investment', 'Social Entrepreneurship', 'Startup Mentoring', ] 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() 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], tagInput: '' } }) ) } const removeTagFromRow = (id: string, tag: string) => { setRows((prev) => prev.map((r) => r.id === id ? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) } : r ) ) } // Get suggestions that haven't been added yet for a specific row const getSuggestionsForRow = (row: MemberRow) => { return SUGGESTED_TAGS.filter( (tag) => !row.expertiseTags.includes(tag) && tag.toLowerCase().includes(row.tagInput.toLowerCase()) ).slice(0, 5) } // 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 === 'MENTOR' ? 'MENTOR' : rawRole === 'OBSERVER' ? 'OBSERVER' : 'JURY_MEMBER' const isValidFormat = emailRegex.test(email) const isDuplicate = email ? seenEmails.has(email) : false if (isValidFormat && !isDuplicate && email) seenEmails.add(email) return { email, name, role, isValid: isValidFormat && !isDuplicate, isDuplicate, error: !email ? 'No email found' : !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined, } }) setParsedUsers(users.filter((u) => u.email)) setStep('preview') }, }) }, [] ) // --- 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) 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, isDuplicate, error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : 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 {/* 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 */}
updateRow(row.id, 'tagInput', e.target.value) } onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault() addTagToRow(row.id, row.tagInput) } }} />
{/* Tag suggestions */} {row.tagInput && getSuggestionsForRow(row).length > 0 && (
{getSuggestionsForRow(row).map((suggestion) => ( ))}
)} {/* Quick suggestions when empty */} {!row.tagInput && row.expertiseTags.length === 0 && (
Suggestions: {SUGGESTED_TAGS.slice(0, 5).map((suggestion) => ( ))}
)} {/* Added tags */} {row.expertiseTags.length > 0 && (
{row.expertiseTags.map((tag) => ( {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()}
) }