MOPC-App/src/app/(admin)/admin/members/invite/page.tsx

701 lines
23 KiB
TypeScript
Raw Normal View History

'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 {
ArrowLeft,
ArrowRight,
AlertCircle,
CheckCircle2,
Loader2,
Users,
X,
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
}
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() {
const [step, setStep] = useState<Step>('input')
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [tagInput, setTagInput] = useState('')
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
const [sendProgress, setSendProgress] = useState(0)
const [result, setResult] = useState<{
created: number
skipped: number
} | null>(null)
const bulkCreate = trpc.user.bulkCreate.useMutation()
// --- 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<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>()
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 handleManualProceed = () => {
const parsed = parseManualRows()
if (parsed.length === 0) return
setParsedUsers(parsed)
setStep('preview')
}
// --- 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)
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:
expertiseTags.length > 0 ? expertiseTags : undefined,
})),
})
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 (
<Card>
<CardHeader>
<CardTitle>Invite Members</CardTitle>
<CardDescription>
Add members individually or upload a CSV file
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Method toggle */}
<div className="flex gap-2">
<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>
{inputMethod === 'manual' ? (
<div className="space-y-3">
{/* 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 className="space-y-2">
<Label>CSV File</Label>
<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" />
<p className="mt-2 font-medium">
Drop CSV file here or click to browse
</p>
<Input
id="csv-input"
type="file"
accept=".csv"
onChange={handleCSVUpload}
className="hidden"
/>
</div>
</div>
)}
{/* Expertise tags */}
<div className="space-y-2">
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
<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()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
Add
</Button>
</div>
{expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{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>
))}
</div>
)}
</div>
{/* Actions */}
<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>
{inputMethod === 'manual' && (
<Button
onClick={handleManualProceed}
disabled={!hasManualData}
>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)
case 'preview':
return (
<Card>
<CardHeader>
<CardTitle>Preview Invitations</CardTitle>
<CardDescription>
Review the list of members to invite
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<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-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>
{summary.invalid > 0 && (
<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>
<Button
variant="outline"
size="sm"
onClick={removeInvalidUsers}
className="shrink-0"
>
Remove Invalid
</Button>
</div>
)}
<div className="rounded-lg border max-h-80 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsedUsers.map((user, index) => (
<TableRow
key={index}
className={cn(!user.isValid && 'bg-red-500/5')}
>
<TableCell>{user.name || '-'}</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>
))}
</TableBody>
</Table>
</div>
<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
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>
</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>
</Card>
)
case 'sending':
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 and sending invitations...
</p>
<Progress value={sendProgress} className="mt-4 w-48" />
</CardContent>
</Card>
)
case 'complete':
return (
<Card>
<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>
<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">
<Button variant="outline" asChild>
<Link href="/admin/members">View Members</Link>
</Button>
<Button onClick={resetForm}>Invite More</Button>
</div>
</CardContent>
</Card>
)
}
}
return (
<div className="space-y-6">
<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>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Invite Members
</h1>
<p className="text-muted-foreground">
Add new members to the platform
</p>
</div>
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{steps.map((s, index) => (
<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'
)}
/>
)}
<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>
{renderStep()}
</div>
)
}