diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 1db3476..672c4c1 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import Papa from 'papaparse' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -15,7 +14,6 @@ import { } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { @@ -41,17 +39,26 @@ import { Loader2, Users, X, - Mail, + Plus, FileSpreadsheet, + UserPlus, } from 'lucide-react' import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' +interface MemberRow { + id: string + name: string + email: string + role: Role +} + interface ParsedUser { email: string name?: string + role: Role isValid: boolean error?: string isDuplicate?: boolean @@ -59,94 +66,213 @@ interface ParsedUser { 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 } +} + export default function MemberInvitePage() { - const router = useRouter() const [step, setStep] = useState('input') - const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea') - const [emailsText, setEmailsText] = useState('') - const [csvFile, setCsvFile] = useState(null) - const [role, setRole] = useState('JURY_MEMBER') + const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') + const [rows, setRows] = useState([createEmptyRow()]) const [expertiseTags, setExpertiseTags] = useState([]) const [tagInput, setTagInput] = useState('') const [parsedUsers, setParsedUsers] = useState([]) const [sendProgress, setSendProgress] = useState(0) - const [result, setResult] = useState<{ created: number; skipped: number } | null>(null) + const [result, setResult] = useState<{ + created: number + skipped: number + } | null>(null) const bulkCreate = trpc.user.bulkCreate.useMutation() - const parseEmailsFromText = useCallback((text: string): ParsedUser[] => { - const lines = text.split(/[\n,;]+/).map((line) => line.trim()).filter(Boolean) + // --- Manual entry helpers --- + const updateRow = (id: string, field: keyof MemberRow, value: 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)]) + } + + // --- 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 lines.map((line) => { - const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/) - const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase() - const name = matchWithName ? matchWithName[1].trim() : undefined - const isValidFormat = emailRegex.test(email) - const isDuplicate = seenEmails.has(email) - if (isValidFormat && !isDuplicate) seenEmails.add(email) - return { - email, name, isValid: isValidFormat && !isDuplicate, isDuplicate, - error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined, - } - }) - }, []) + 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, + isValid: isValidFormat && !isDuplicate, + isDuplicate, + error: !isValidFormat + ? 'Invalid email format' + : isDuplicate + ? 'Duplicate email' + : undefined, + } + }) + } - const handleCSVUpload = useCallback((e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - setCsvFile(file) - 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 email = emailKey ? row[emailKey]?.trim().toLowerCase() : '' - const name = nameKey ? row[nameKey]?.trim() : undefined - const isValidFormat = emailRegex.test(email) - const isDuplicate = email ? seenEmails.has(email) : false - if (isValidFormat && !isDuplicate && email) seenEmails.add(email) - return { - email, name, 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') - }, - }) - }, []) + const handleManualProceed = () => { + const parsed = parseManualRows() + if (parsed.length === 0) return + setParsedUsers(parsed) + setStep('preview') + } - const handleTextProceed = () => { setParsedUsers(parseEmailsFromText(emailsText)); setStep('preview') } - const addTag = () => { const tag = tagInput.trim(); if (tag && !expertiseTags.includes(tag)) { setExpertiseTags([...expertiseTags, tag]); setTagInput('') } } - const removeTag = (tag: string) => setExpertiseTags(expertiseTags.filter((t) => t !== tag)) + // --- Tags --- + const addTag = () => { + const tag = tagInput.trim() + if (tag && !expertiseTags.includes(tag)) { + setExpertiseTags([...expertiseTags, tag]) + setTagInput('') + } + } + const removeTag = (tag: string) => + setExpertiseTags(expertiseTags.filter((t) => t !== tag)) + // --- Summary --- const summary = useMemo(() => { const validUsers = parsedUsers.filter((u) => u.isValid) const invalidUsers = parsedUsers.filter((u) => !u.isValid) - const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate) - return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, duplicates: duplicateUsers.length, validUsers, invalidUsers, duplicateUsers } + return { + total: parsedUsers.length, + valid: validUsers.length, + invalid: invalidUsers.length, + validUsers, + invalidUsers, + } }, [parsedUsers]) - const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid)) + const removeInvalidUsers = () => + setParsedUsers(parsedUsers.filter((u) => u.isValid)) + // --- Send --- const handleSendInvites = async () => { if (summary.valid === 0) return - setStep('sending'); setSendProgress(0) + setStep('sending') + setSendProgress(0) try { const result = await bulkCreate.mutateAsync({ - users: summary.validUsers.map((u) => ({ email: u.email, name: u.name, role, expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined })), + users: summary.validUsers.map((u) => ({ + email: u.email, + name: u.name, + role: u.role, + expertiseTags: + expertiseTags.length > 0 ? expertiseTags : undefined, + })), }) - setSendProgress(100); setResult(result); setStep('complete') - } catch { setStep('preview') } + setSendProgress(100) + setResult(result) + setStep('complete') + } catch { + setStep('preview') + } } - const resetForm = () => { setStep('input'); setEmailsText(''); setCsvFile(null); setParsedUsers([]); setResult(null); setSendProgress(0) } + 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' }, + { key: 'input', label: 'Input' }, + { key: 'preview', label: 'Preview' }, + { key: 'sending', label: 'Send' }, + { key: 'complete', label: 'Done' }, ] const currentStepIndex = steps.findIndex((s) => s.key === step) @@ -157,114 +283,361 @@ export default function MemberInvitePage() { Invite Members - Add email addresses to invite new members to the platform + + Add members individually or upload a CSV file + + {/* Method toggle */}
- - + +
- {inputMethod === 'textarea' ? ( -
- -