Redesign member invite page with per-member form rows
Build and Push Docker Image / build (push) Waiting to run
Details
Build and Push Docker Image / build (push) Waiting to run
Details
Replace bulk email textarea with individual name/email/role rows. Each member gets their own entry with full name, email, and role selector. New rows inherit the role from the previous row. CSV import kept as an alternative tab. Invites sent automatically on creation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8931da98ba
commit
0b3c2b6804
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import Papa from 'papaparse'
|
import Papa from 'papaparse'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -15,7 +14,6 @@ import {
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,17 +39,26 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
Mail,
|
Plus,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
|
interface MemberRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
role: Role
|
||||||
|
}
|
||||||
|
|
||||||
interface ParsedUser {
|
interface ParsedUser {
|
||||||
email: string
|
email: string
|
||||||
name?: string
|
name?: string
|
||||||
|
role: Role
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
error?: string
|
error?: string
|
||||||
isDuplicate?: boolean
|
isDuplicate?: boolean
|
||||||
|
|
@ -59,94 +66,213 @@ interface ParsedUser {
|
||||||
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
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() {
|
export default function MemberInvitePage() {
|
||||||
const router = useRouter()
|
|
||||||
const [step, setStep] = useState<Step>('input')
|
const [step, setStep] = useState<Step>('input')
|
||||||
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
|
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||||
const [emailsText, setEmailsText] = useState('')
|
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
|
||||||
const [role, setRole] = useState<Role>('JURY_MEMBER')
|
|
||||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||||
const [tagInput, setTagInput] = useState('')
|
const [tagInput, setTagInput] = useState('')
|
||||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
||||||
const [sendProgress, setSendProgress] = useState(0)
|
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 bulkCreate = trpc.user.bulkCreate.useMutation()
|
||||||
|
|
||||||
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
|
// --- Manual entry helpers ---
|
||||||
const lines = text.split(/[\n,;]+/).map((line) => line.trim()).filter(Boolean)
|
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<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
Papa.parse<Record<string, string>>(file, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
complete: (results) => {
|
||||||
|
const seenEmails = new Set<string>()
|
||||||
|
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<string>()
|
const seenEmails = new Set<string>()
|
||||||
return lines.map((line) => {
|
return rows
|
||||||
const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/)
|
.filter((r) => r.email.trim())
|
||||||
const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase()
|
.map((r) => {
|
||||||
const name = matchWithName ? matchWithName[1].trim() : undefined
|
const email = r.email.trim().toLowerCase()
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = seenEmails.has(email)
|
const isDuplicate = seenEmails.has(email)
|
||||||
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
||||||
return {
|
return {
|
||||||
email, name, isValid: isValidFormat && !isDuplicate, isDuplicate,
|
email,
|
||||||
error: !isValidFormat ? 'Invalid email format' : isDuplicate ? 'Duplicate email' : undefined,
|
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<HTMLInputElement>) => {
|
const handleManualProceed = () => {
|
||||||
const file = e.target.files?.[0]
|
const parsed = parseManualRows()
|
||||||
if (!file) return
|
if (parsed.length === 0) return
|
||||||
setCsvFile(file)
|
setParsedUsers(parsed)
|
||||||
Papa.parse<Record<string, string>>(file, {
|
setStep('preview')
|
||||||
header: true, skipEmptyLines: true,
|
}
|
||||||
complete: (results) => {
|
|
||||||
const seenEmails = new Set<string>()
|
|
||||||
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 handleTextProceed = () => { setParsedUsers(parseEmailsFromText(emailsText)); setStep('preview') }
|
// --- Tags ---
|
||||||
const addTag = () => { const tag = tagInput.trim(); if (tag && !expertiseTags.includes(tag)) { setExpertiseTags([...expertiseTags, tag]); setTagInput('') } }
|
const addTag = () => {
|
||||||
const removeTag = (tag: string) => setExpertiseTags(expertiseTags.filter((t) => t !== tag))
|
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 summary = useMemo(() => {
|
||||||
const validUsers = parsedUsers.filter((u) => u.isValid)
|
const validUsers = parsedUsers.filter((u) => u.isValid)
|
||||||
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
|
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
|
||||||
const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate)
|
return {
|
||||||
return { total: parsedUsers.length, valid: validUsers.length, invalid: invalidUsers.length, duplicates: duplicateUsers.length, validUsers, invalidUsers, duplicateUsers }
|
total: parsedUsers.length,
|
||||||
|
valid: validUsers.length,
|
||||||
|
invalid: invalidUsers.length,
|
||||||
|
validUsers,
|
||||||
|
invalidUsers,
|
||||||
|
}
|
||||||
}, [parsedUsers])
|
}, [parsedUsers])
|
||||||
|
|
||||||
const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
const removeInvalidUsers = () =>
|
||||||
|
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||||
|
|
||||||
|
// --- Send ---
|
||||||
const handleSendInvites = async () => {
|
const handleSendInvites = async () => {
|
||||||
if (summary.valid === 0) return
|
if (summary.valid === 0) return
|
||||||
setStep('sending'); setSendProgress(0)
|
setStep('sending')
|
||||||
|
setSendProgress(0)
|
||||||
try {
|
try {
|
||||||
const result = await bulkCreate.mutateAsync({
|
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')
|
setSendProgress(100)
|
||||||
} catch { setStep('preview') }
|
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 }> = [
|
const steps: Array<{ key: Step; label: string }> = [
|
||||||
{ key: 'input', label: 'Input' }, { key: 'preview', label: 'Preview' },
|
{ key: 'input', label: 'Input' },
|
||||||
{ key: 'sending', label: 'Send' }, { key: 'complete', label: 'Done' },
|
{ key: 'preview', label: 'Preview' },
|
||||||
|
{ key: 'sending', label: 'Send' },
|
||||||
|
{ key: 'complete', label: 'Done' },
|
||||||
]
|
]
|
||||||
const currentStepIndex = steps.findIndex((s) => s.key === step)
|
const currentStepIndex = steps.findIndex((s) => s.key === step)
|
||||||
|
|
||||||
|
|
@ -157,114 +283,361 @@ export default function MemberInvitePage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Invite Members</CardTitle>
|
<CardTitle>Invite Members</CardTitle>
|
||||||
<CardDescription>Add email addresses to invite new members to the platform</CardDescription>
|
<CardDescription>
|
||||||
|
Add members individually or upload a CSV file
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
{/* Method toggle */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant={inputMethod === 'textarea' ? 'default' : 'outline'} size="sm" onClick={() => setInputMethod('textarea')}><Mail className="mr-2 h-4 w-4" />Enter Emails</Button>
|
<Button
|
||||||
<Button type="button" variant={inputMethod === 'csv' ? 'default' : 'outline'} size="sm" onClick={() => setInputMethod('csv')}><FileSpreadsheet className="mr-2 h-4 w-4" />Upload CSV</Button>
|
type="button"
|
||||||
|
variant={inputMethod === 'manual' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMethod('manual')}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Add Manually
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={inputMethod === 'csv' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInputMethod('csv')}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Upload CSV
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{inputMethod === 'textarea' ? (
|
|
||||||
<div className="space-y-2">
|
{inputMethod === 'manual' ? (
|
||||||
<Label htmlFor="emails">Email Addresses</Label>
|
<div className="space-y-3">
|
||||||
<Textarea id="emails" value={emailsText} onChange={(e) => setEmailsText(e.target.value)} placeholder="Enter email addresses, one per line or comma-separated." rows={8} maxLength={10000} className="font-mono text-sm" />
|
{/* Column headers */}
|
||||||
|
<div className="hidden sm:grid sm:grid-cols-[1fr_1fr_140px_36px] gap-2 px-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Role
|
||||||
|
</Label>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid gap-2 sm:grid-cols-[1fr_1fr_140px_36px]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Full name"
|
||||||
|
value={row.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, 'name', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={row.email}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRow(row.id, 'email', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={row.role}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateRow(row.id, 'role', v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="JURY_MEMBER">
|
||||||
|
Jury Member
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||||
|
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeRow(row.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addRow}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add another member
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>CSV File</Label>
|
<Label>CSV File</Label>
|
||||||
<div className={cn('border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors', 'hover:border-primary/50')} onClick={() => document.getElementById('csv-input')?.click()}>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
CSV should have columns: name, email, role (optional)
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
|
||||||
|
'hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById('csv-input')?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
|
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||||
<p className="mt-2 font-medium">{csvFile ? csvFile.name : 'Drop CSV file here or click to browse'}</p>
|
<p className="mt-2 font-medium">
|
||||||
<Input id="csv-input" type="file" accept=".csv" onChange={handleCSVUpload} className="hidden" />
|
Drop CSV file here or click to browse
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="csv-input"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleCSVUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role">Role</Label>
|
{/* Expertise tags */}
|
||||||
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
|
||||||
<SelectTrigger id="role"><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
|
||||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
|
||||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
|
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input id="expertise" value={tagInput} onChange={(e) => setTagInput(e.target.value)} placeholder="e.g., Marine Biology" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag() } }} />
|
<Input
|
||||||
<Button type="button" variant="outline" onClick={addTag}>Add</Button>
|
id="expertise"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
placeholder="e.g., Marine Biology"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={addTag}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{expertiseTags.length > 0 && (
|
{expertiseTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
{expertiseTags.map((tag) => (
|
{expertiseTags.map((tag) => (
|
||||||
<Badge key={tag} variant="secondary" className="gap-1">{tag}<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-destructive"><X className="h-3 w-3" /></button></Badge>
|
<Badge key={tag} variant="secondary" className="gap-1">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="ml-1 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<Button variant="outline" asChild><Link href="/admin/members"><ArrowLeft className="mr-2 h-4 w-4" />Cancel</Link></Button>
|
<Button variant="outline" asChild>
|
||||||
<Button onClick={handleTextProceed} disabled={inputMethod === 'textarea' && !emailsText.trim()}>Preview<ArrowRight className="ml-2 h-4 w-4" /></Button>
|
<Link href="/admin/members">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{inputMethod === 'manual' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleManualProceed}
|
||||||
|
disabled={!hasManualData}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'preview':
|
case 'preview':
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Preview Invitations</CardTitle><CardDescription>Review the list of users to invite</CardDescription></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>Preview Invitations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review the list of members to invite
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="rounded-lg bg-muted p-4 text-center"><p className="text-3xl font-bold">{summary.total}</p><p className="text-sm text-muted-foreground">Total</p></div>
|
<div className="rounded-lg bg-muted p-4 text-center">
|
||||||
<div className="rounded-lg bg-green-500/10 p-4 text-center"><p className="text-3xl font-bold text-green-600">{summary.valid}</p><p className="text-sm text-muted-foreground">Valid</p></div>
|
<p className="text-3xl font-bold">{summary.total}</p>
|
||||||
<div className="rounded-lg bg-red-500/10 p-4 text-center"><p className="text-3xl font-bold text-red-600">{summary.invalid}</p><p className="text-sm text-muted-foreground">Invalid</p></div>
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-green-500/10 p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-600">
|
||||||
|
{summary.valid}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Valid</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-red-500/10 p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-red-600">
|
||||||
|
{summary.invalid}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Invalid</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary.invalid > 0 && (
|
{summary.invalid > 0 && (
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
|
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" /><div className="flex-1"><p className="font-medium">{summary.invalid} email(s) have issues</p></div>
|
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
<Button variant="outline" size="sm" onClick={removeInvalidUsers} className="shrink-0">Remove Invalid</Button>
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{summary.invalid} email(s) have issues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={removeInvalidUsers}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Remove Invalid
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border max-h-80 overflow-y-auto">
|
<div className="rounded-lg border max-h-80 overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Email</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{parsedUsers.map((user, index) => (
|
{parsedUsers.map((user, index) => (
|
||||||
<TableRow key={index} className={cn(!user.isValid && 'bg-red-500/5')}>
|
<TableRow
|
||||||
<TableCell className="font-mono text-sm">{user.email}</TableCell>
|
key={index}
|
||||||
|
className={cn(!user.isValid && 'bg-red-500/5')}
|
||||||
|
>
|
||||||
<TableCell>{user.name || '-'}</TableCell>
|
<TableCell>{user.name || '-'}</TableCell>
|
||||||
<TableCell>{user.isValid ? <Badge variant="outline" className="text-green-600"><CheckCircle2 className="mr-1 h-3 w-3" />Valid</Badge> : <Badge variant="destructive">{user.error}</Badge>}</TableCell>
|
<TableCell className="font-mono text-sm">
|
||||||
|
{user.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ROLE_LABELS[user.role]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.isValid ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-green-600"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Valid
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive">{user.error}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<Button variant="outline" onClick={() => { setParsedUsers([]); setStep('input') }}><ArrowLeft className="mr-2 h-4 w-4" />Back</Button>
|
<Button
|
||||||
<Button onClick={handleSendInvites} disabled={summary.valid === 0 || bulkCreate.isPending}>
|
variant="outline"
|
||||||
{bulkCreate.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Users className="mr-2 h-4 w-4" />}
|
onClick={() => {
|
||||||
Create {summary.valid} Member{summary.valid !== 1 ? 's' : ''}
|
setParsedUsers([])
|
||||||
|
setStep('input')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendInvites}
|
||||||
|
disabled={summary.valid === 0 || bulkCreate.isPending}
|
||||||
|
>
|
||||||
|
{bulkCreate.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Create & Invite {summary.valid} Member
|
||||||
|
{summary.valid !== 1 ? 's' : ''}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{bulkCreate.error && <div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive"><AlertCircle className="h-5 w-5" /><span>{bulkCreate.error.message}</span></div>}
|
|
||||||
|
{bulkCreate.error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{bulkCreate.error.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'sending':
|
case 'sending':
|
||||||
return (
|
return (
|
||||||
<Card><CardContent className="flex flex-col items-center justify-center py-12"><Loader2 className="h-12 w-12 animate-spin text-primary" /><p className="mt-4 font-medium">Creating members...</p><Progress value={sendProgress} className="mt-4 w-48" /></CardContent></Card>
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
|
<p className="mt-4 font-medium">
|
||||||
|
Creating members and sending invitations...
|
||||||
|
</p>
|
||||||
|
<Progress value={sendProgress} className="mt-4 w-48" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'complete':
|
case 'complete':
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10"><CheckCircle2 className="h-8 w-8 text-green-600" /></div>
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
|
||||||
<p className="mt-4 text-xl font-semibold">Members Created!</p>
|
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||||
<p className="text-muted-foreground text-center max-w-sm mt-2">{result?.created} member{result?.created !== 1 ? 's' : ''} created successfully.{result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}</p>
|
</div>
|
||||||
|
<p className="mt-4 text-xl font-semibold">
|
||||||
|
Invitations Sent!
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||||
|
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
||||||
|
created and invited.
|
||||||
|
{result?.skipped
|
||||||
|
? ` ${result.skipped} skipped (already exist).`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
<Button variant="outline" asChild><Link href="/admin/members">View Members</Link></Button>
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/members">View Members</Link>
|
||||||
|
</Button>
|
||||||
<Button onClick={resetForm}>Invite More</Button>
|
<Button onClick={resetForm}>Invite More</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -276,20 +649,51 @@ export default function MemberInvitePage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-4"><Link href="/admin/members"><ArrowLeft className="mr-2 h-4 w-4" />Back to Members</Link></Button>
|
<Button variant="ghost" asChild className="-ml-4">
|
||||||
|
<Link href="/admin/members">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Members
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Invite Members</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<p className="text-muted-foreground">Add new members to the platform</p>
|
Invite Members
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Add new members to the platform
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{steps.map((s, index) => (
|
{steps.map((s, index) => (
|
||||||
<div key={s.key} className="flex items-center">
|
<div key={s.key} className="flex items-center">
|
||||||
{index > 0 && <div className={cn('h-0.5 w-8 mx-1', index <= currentStepIndex ? 'bg-primary' : 'bg-muted')} />}
|
{index > 0 && (
|
||||||
<div className={cn('flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium', index === currentStepIndex ? 'bg-primary text-primary-foreground' : index < currentStepIndex ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground')}>{index + 1}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-0.5 w-8 mx-1',
|
||||||
|
index <= currentStepIndex ? 'bg-primary' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
|
||||||
|
index === currentStepIndex
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: index < currentStepIndex
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue