Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards
- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination - Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence - Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility - Founding Date Field: add foundedAt to Project model with CSV import support - Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate - Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility - Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures - Reusable pagination component extracted to src/components/shared/pagination.tsx - Old /admin/users and /admin/mentors routes redirect to /admin/members - Prisma migration for all schema additions (additive, no data loss) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
377
src/app/(admin)/admin/members/[id]/page.tsx
Normal file
377
src/app/(admin)/admin/members/[id]/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { toast } from 'sonner'
|
||||
import { TagInput } from '@/components/shared/tag-input'
|
||||
import { UserActivityLog } from '@/components/shared/user-activity-log'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Mail,
|
||||
User,
|
||||
Shield,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function MemberDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const userId = params.id as string
|
||||
|
||||
const { data: user, isLoading, refetch } = trpc.user.get.useQuery({ id: userId })
|
||||
const updateUser = trpc.user.update.useMutation()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
|
||||
// Mentor assignments (only fetched for mentors)
|
||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||
{ mentorId: userId, page: 1, perPage: 50 },
|
||||
{ enabled: user?.role === 'MENTOR' }
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [role, setRole] = useState<string>('JURY_MEMBER')
|
||||
const [status, setStatus] = useState<string>('INVITED')
|
||||
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setName(user.name || '')
|
||||
setRole(user.role)
|
||||
setStatus(user.status)
|
||||
setExpertiseTags(user.expertiseTags || [])
|
||||
setMaxAssignments(user.maxAssignments?.toString() || '')
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateUser.mutateAsync({
|
||||
id: userId,
|
||||
name: name || null,
|
||||
role: role as 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN',
|
||||
status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED',
|
||||
expertiseTags,
|
||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||
})
|
||||
toast.success('Member updated successfully')
|
||||
router.push('/admin/members')
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update member')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
try {
|
||||
await sendInvitation.mutateAsync({ userId })
|
||||
toast.success('Invitation email sent successfully')
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Member not found</AlertTitle>
|
||||
<AlertDescription>
|
||||
The member you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/members">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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 className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{user.name || 'Unnamed Member'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
<Badge variant={user.status === 'ACTIVE' ? 'success' : user.status === 'SUSPENDED' ? 'destructive' : 'secondary'}>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{user.status === 'INVITED' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSendInvitation}
|
||||
disabled={sendInvitation.isPending}
|
||||
>
|
||||
{sendInvitation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Send Invitation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Basic Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INVITED">Invited</SelectItem>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="SUSPENDED">Suspended</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expertise & Capacity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Expertise & Capacity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Expertise Tags</Label>
|
||||
<TagInput
|
||||
value={expertiseTags}
|
||||
onChange={setExpertiseTags}
|
||||
placeholder="Select expertise tags..."
|
||||
maxTags={15}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxAssignments">Max Assignments</Label>
|
||||
<Input
|
||||
id="maxAssignments"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={maxAssignments}
|
||||
onChange={(e) => setMaxAssignments(e.target.value)}
|
||||
placeholder="Unlimited"
|
||||
/>
|
||||
</div>
|
||||
{user._count && (
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-2">Statistics</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Jury Assignments</p>
|
||||
<p className="text-2xl font-semibold">{user._count.assignments}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Mentor Assignments</p>
|
||||
<p className="text-2xl font-semibold">{user._count.mentorAssignments}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Mentor Assignments Section */}
|
||||
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mentored Projects</CardTitle>
|
||||
<CardDescription>
|
||||
Projects this mentor is assigned to
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Assigned</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mentorAssignments.assignments.map((assignment) => (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/projects/${assignment.project.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{assignment.project.title}
|
||||
</Link>
|
||||
{assignment.project.teamName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{assignment.project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assignment.project.competitionCategory ? (
|
||||
<Badge variant="outline">
|
||||
{assignment.project.competitionCategory.replace('_', ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{assignment.project.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(assignment.assignedAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Activity Log */}
|
||||
<UserActivityLog userId={userId} />
|
||||
|
||||
{/* Status Alert */}
|
||||
{user.status === 'INVITED' && (
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
<AlertTitle>Invitation Pending</AlertTitle>
|
||||
<AlertDescription>
|
||||
This member hasn't accepted their invitation yet. You can resend the
|
||||
invitation email using the button above.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/members">Cancel</Link>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={updateUser.isPending}>
|
||||
{updateUser.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
296
src/app/(admin)/admin/members/invite/page.tsx
Normal file
296
src/app/(admin)/admin/members/invite/page.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
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,
|
||||
Mail,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
interface ParsedUser {
|
||||
email: string
|
||||
name?: string
|
||||
isValid: boolean
|
||||
error?: string
|
||||
isDuplicate?: boolean
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export default function MemberInvitePage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
|
||||
const [emailsText, setEmailsText] = useState('')
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [role, setRole] = useState<Role>('JURY_MEMBER')
|
||||
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()
|
||||
|
||||
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
|
||||
const lines = text.split(/[\n,;]+/).map((line) => line.trim()).filter(Boolean)
|
||||
const seenEmails = new Set<string>()
|
||||
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,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCSVUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setCsvFile(file)
|
||||
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 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') }
|
||||
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))
|
||||
|
||||
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 }
|
||||
}, [parsedUsers])
|
||||
|
||||
const removeInvalidUsers = () => setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||
|
||||
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, expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined })),
|
||||
})
|
||||
setSendProgress(100); setResult(result); setStep('complete')
|
||||
} catch { setStep('preview') }
|
||||
}
|
||||
|
||||
const resetForm = () => { setStep('input'); setEmailsText(''); setCsvFile(null); setParsedUsers([]); setResult(null); setSendProgress(0) }
|
||||
|
||||
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 email addresses to invite new members to the platform</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<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 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 === 'textarea' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emails">Email Addresses</Label>
|
||||
<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" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<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()}>
|
||||
<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>
|
||||
<Input id="csv-input" type="file" accept=".csv" onChange={handleCSVUpload} className="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<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">
|
||||
<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>
|
||||
<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 onClick={handleTextProceed} disabled={inputMethod === 'textarea' && !emailsText.trim()}>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 users 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>Email</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{parsedUsers.map((user, index) => (
|
||||
<TableRow key={index} className={cn(!user.isValid && 'bg-red-500/5')}>
|
||||
<TableCell className="font-mono text-sm">{user.email}</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>
|
||||
</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 {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...</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">Members Created!</p>
|
||||
<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 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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
7
src/app/(admin)/admin/members/page.tsx
Normal file
7
src/app/(admin)/admin/members/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MembersContent } from '@/components/admin/members-content'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function MembersPage() {
|
||||
return <MembersContent />
|
||||
}
|
||||
Reference in New Issue
Block a user