1096 lines
40 KiB
TypeScript
1096 lines
40 KiB
TypeScript
'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 {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Loader2,
|
|
Users,
|
|
X,
|
|
Plus,
|
|
FileSpreadsheet,
|
|
UserPlus,
|
|
FolderKanban,
|
|
ChevronDown,
|
|
Check,
|
|
Tags,
|
|
Mail,
|
|
MailX,
|
|
} from 'lucide-react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
|
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
|
|
|
interface Assignment {
|
|
projectId: string
|
|
stageId: string
|
|
}
|
|
|
|
interface MemberRow {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
role: Role
|
|
expertiseTags: 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<Role, string> = {
|
|
SUPER_ADMIN: 'Super Admin',
|
|
PROGRAM_ADMIN: 'Program Admin',
|
|
AWARD_MASTER: 'Award Master',
|
|
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: [], assignments: [] }
|
|
}
|
|
|
|
/** Inline tag picker with grouped dropdown from database tags */
|
|
function TagPicker({
|
|
selectedTags,
|
|
onAdd,
|
|
onRemove,
|
|
}: {
|
|
selectedTags: string[]
|
|
onAdd: (tag: string) => void
|
|
onRemove: (tag: string) => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true })
|
|
const tags = data?.tags || []
|
|
|
|
const tagsByCategory = useMemo(() => {
|
|
const grouped: Record<string, typeof tags> = {}
|
|
for (const tag of tags) {
|
|
const category = tag.category || 'Other'
|
|
if (!grouped[category]) grouped[category] = []
|
|
grouped[category].push(tag)
|
|
}
|
|
return grouped
|
|
}, [tags])
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">
|
|
Expertise Tags (optional)
|
|
</Label>
|
|
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="w-full justify-between font-normal text-muted-foreground"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Tags className="h-4 w-4" />
|
|
{selectedTags.length > 0
|
|
? `${selectedTags.length} tag${selectedTags.length !== 1 ? 's' : ''} selected`
|
|
: 'Select expertise tags...'}
|
|
</span>
|
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[350px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="Search tags..." />
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
{isLoading ? 'Loading tags...' : 'No tags found.'}
|
|
</CommandEmpty>
|
|
{Object.entries(tagsByCategory)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([category, categoryTags]) => (
|
|
<CommandGroup key={category} heading={category}>
|
|
{categoryTags.map((tag) => {
|
|
const isSelected = selectedTags.includes(tag.name)
|
|
return (
|
|
<CommandItem
|
|
key={tag.id}
|
|
value={tag.name}
|
|
onSelect={() => {
|
|
if (isSelected) {
|
|
onRemove(tag.name)
|
|
} else {
|
|
onAdd(tag.name)
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'mr-2 flex h-4 w-4 items-center justify-center rounded border',
|
|
isSelected
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
: 'border-muted-foreground/30'
|
|
)}
|
|
style={{
|
|
borderColor: isSelected && tag.color ? tag.color : undefined,
|
|
backgroundColor: isSelected && tag.color ? tag.color : undefined,
|
|
}}
|
|
>
|
|
{isSelected && <Check className="h-3 w-3" />}
|
|
</div>
|
|
<span className="flex-1 truncate">{tag.name}</span>
|
|
{tag.color && (
|
|
<span
|
|
className="h-2.5 w-2.5 rounded-full shrink-0"
|
|
style={{ backgroundColor: tag.color }}
|
|
/>
|
|
)}
|
|
</CommandItem>
|
|
)
|
|
})}
|
|
</CommandGroup>
|
|
))}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{selectedTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{selectedTags.map((tagName) => {
|
|
const tagData = tags.find((t) => t.name === tagName)
|
|
return (
|
|
<Badge
|
|
key={tagName}
|
|
variant="secondary"
|
|
className="gap-1 pr-1"
|
|
style={{
|
|
backgroundColor: tagData?.color ? `${tagData.color}15` : undefined,
|
|
borderColor: tagData?.color || undefined,
|
|
color: tagData?.color || undefined,
|
|
}}
|
|
>
|
|
{tagName}
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemove(tagName)}
|
|
className="ml-1 hover:text-destructive rounded-full"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function MemberInvitePage() {
|
|
const [step, setStep] = useState<Step>('input')
|
|
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
|
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
|
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
|
const [sendProgress, setSendProgress] = useState(0)
|
|
const [sendInvitation, setSendInvitation] = useState(true)
|
|
const [result, setResult] = useState<{
|
|
created: number
|
|
skipped: number
|
|
assignmentsCreated?: number
|
|
invitationSent?: boolean
|
|
} | null>(null)
|
|
|
|
// Pre-assignment state
|
|
const [selectedStageId, setSelectedStageId] = useState<string>('')
|
|
|
|
const utils = trpc.useUtils()
|
|
|
|
// Use session role directly (from JWT) — no DB query needed, works even with stale user IDs
|
|
const { data: session } = useSession()
|
|
const isSuperAdmin = session?.user?.role === 'SUPER_ADMIN'
|
|
const isAdmin = isSuperAdmin || session?.user?.role === 'PROGRAM_ADMIN'
|
|
|
|
// Compute available roles as a stable list — avoids Radix Select
|
|
// not re-rendering conditional children when async data loads
|
|
const availableRoles = useMemo((): Role[] => {
|
|
const roles: Role[] = []
|
|
if (isSuperAdmin) roles.push('SUPER_ADMIN')
|
|
if (isAdmin) roles.push('PROGRAM_ADMIN', 'AWARD_MASTER')
|
|
roles.push('JURY_MEMBER', 'MENTOR', 'OBSERVER')
|
|
return roles
|
|
}, [isSuperAdmin, isAdmin])
|
|
|
|
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 stages for pre-assignment
|
|
const { data: programsData } = trpc.program.list.useQuery({
|
|
status: 'ACTIVE',
|
|
includeStages: true,
|
|
})
|
|
// Flatten all stages from all programs
|
|
const stages = useMemo(() => {
|
|
if (!programsData) return []
|
|
return programsData.flatMap((program) =>
|
|
((program.stages ?? []) as Array<{ id: string; name: string }>).map((stage: { id: string; name: string }) => ({
|
|
id: stage.id,
|
|
name: stage.name,
|
|
programName: `${program.name} ${program.year}`,
|
|
}))
|
|
)
|
|
}, [programsData])
|
|
|
|
// Fetch projects for selected stage
|
|
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
|
|
{ stageId: selectedStageId, perPage: 200 },
|
|
{ enabled: !!selectedStageId }
|
|
)
|
|
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] }
|
|
})
|
|
)
|
|
}
|
|
|
|
const removeTagFromRow = (id: string, tag: string) => {
|
|
setRows((prev) =>
|
|
prev.map((r) =>
|
|
r.id === id
|
|
? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) }
|
|
: r
|
|
)
|
|
)
|
|
}
|
|
|
|
// Per-row project assignment management
|
|
const toggleProjectAssignment = (rowId: string, projectId: string) => {
|
|
if (!selectedStageId) 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, stageId: selectedStageId }] }
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
const clearRowAssignments = (rowId: string) => {
|
|
setRows((prev) =>
|
|
prev.map((r) => (r.id === rowId ? { ...r, assignments: [] } : r))
|
|
)
|
|
}
|
|
|
|
// --- 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 === 'SUPER_ADMIN'
|
|
? 'SUPER_ADMIN'
|
|
: rawRole === 'PROGRAM_ADMIN'
|
|
? 'PROGRAM_ADMIN'
|
|
: rawRole === 'AWARD_MASTER'
|
|
? 'AWARD_MASTER'
|
|
: rawRole === 'MENTOR'
|
|
? 'MENTOR'
|
|
: rawRole === 'OBSERVER'
|
|
? 'OBSERVER'
|
|
: 'JURY_MEMBER'
|
|
const isValidFormat = emailRegex.test(email)
|
|
const isDuplicate = email ? seenEmails.has(email) : false
|
|
const isUnauthorizedAdmin = role === 'SUPER_ADMIN' && !isSuperAdmin
|
|
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
|
return {
|
|
email,
|
|
name,
|
|
role,
|
|
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
|
|
isDuplicate,
|
|
error: !email
|
|
? 'No email found'
|
|
: !isValidFormat
|
|
? 'Invalid email format'
|
|
: isDuplicate
|
|
? 'Duplicate email'
|
|
: isUnauthorizedAdmin
|
|
? 'Only super admins can invite super admins'
|
|
: undefined,
|
|
}
|
|
})
|
|
setParsedUsers(users.filter((u) => u.email))
|
|
setStep('preview')
|
|
},
|
|
})
|
|
},
|
|
[isSuperAdmin]
|
|
)
|
|
|
|
// --- 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)
|
|
const isUnauthorizedAdmin = r.role === 'SUPER_ADMIN' && !isSuperAdmin
|
|
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 && !isUnauthorizedAdmin,
|
|
isDuplicate,
|
|
error: !isValidFormat
|
|
? 'Invalid email format'
|
|
: isDuplicate
|
|
? 'Duplicate email'
|
|
: isUnauthorizedAdmin
|
|
? 'Only super admins can invite super admins'
|
|
: 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,
|
|
})),
|
|
sendInvitation,
|
|
})
|
|
setSendProgress(100)
|
|
setResult(result)
|
|
setStep('complete')
|
|
} catch {
|
|
setStep('preview')
|
|
}
|
|
}
|
|
|
|
const resetForm = () => {
|
|
setStep('input')
|
|
setRows([createEmptyRow()])
|
|
setParsedUsers([])
|
|
setResult(null)
|
|
setSendProgress(0)
|
|
setSendInvitation(true)
|
|
}
|
|
|
|
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
|
|
{isSuperAdmin && (
|
|
<span className="block mt-1 text-primary font-medium">
|
|
As a super admin, you can also invite super admins
|
|
</span>
|
|
)}
|
|
</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-4">
|
|
{/* Round selector for pre-assignments */}
|
|
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
|
|
<div className="flex items-center gap-3">
|
|
<FolderKanban className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select a stage to assign projects to jury members before they onboard
|
|
</p>
|
|
</div>
|
|
<Select
|
|
value={selectedStageId || 'none'}
|
|
onValueChange={(v) => setSelectedStageId(v === 'none' ? '' : v)}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="Select stage" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">No pre-assignment</SelectItem>
|
|
{stages.map((stage) => (
|
|
<SelectItem key={stage.id} value={stage.id}>
|
|
{stage.programName} - {stage.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Member cards */}
|
|
{rows.map((row, index) => (
|
|
<div
|
|
key={row.id}
|
|
className="rounded-lg border p-4 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Member {index + 1}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeRow(row.id)}
|
|
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_140px]">
|
|
<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>
|
|
{availableRoles.map((role) => (
|
|
<SelectItem key={role} value={role}>
|
|
{ROLE_LABELS[role]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Per-member expertise tags */}
|
|
<TagPicker
|
|
selectedTags={row.expertiseTags}
|
|
onAdd={(tag) => addTagToRow(row.id, tag)}
|
|
onRemove={(tag) => removeTagFromRow(row.id, tag)}
|
|
/>
|
|
|
|
{/* Per-member project pre-assignment (only for jury members) */}
|
|
{row.role === 'JURY_MEMBER' && selectedStageId && (
|
|
<Collapsible className="space-y-2">
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-between text-muted-foreground hover:text-foreground"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<FolderKanban className="h-4 w-4" />
|
|
Pre-assign Projects
|
|
{row.assignments.length > 0 && (
|
|
<Badge variant="secondary" className="ml-1">
|
|
{row.assignments.length}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4" />
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-2">
|
|
{projectsLoading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
<span className="text-sm text-muted-foreground">Loading projects...</span>
|
|
</div>
|
|
) : projects.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">
|
|
No projects in this round
|
|
</p>
|
|
) : (
|
|
<div className="max-h-48 overflow-y-auto space-y-1 border rounded-lg p-2">
|
|
{projects.map((project) => {
|
|
const isAssigned = row.assignments.some(
|
|
(a) => a.projectId === project.id
|
|
)
|
|
return (
|
|
<label
|
|
key={project.id}
|
|
className={cn(
|
|
'flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted',
|
|
isAssigned && 'bg-primary/5'
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={isAssigned}
|
|
onCheckedChange={() =>
|
|
toggleProjectAssignment(row.id, project.id)
|
|
}
|
|
/>
|
|
<span className="text-sm truncate flex-1">
|
|
{project.title}
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
{row.assignments.length > 0 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => clearRowAssignments(row.id)}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Clear all assignments
|
|
</Button>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* Invitation toggle */}
|
|
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
|
|
<div className="flex items-center gap-3">
|
|
{sendInvitation ? (
|
|
<Mail className="h-5 w-5 text-primary shrink-0" />
|
|
) : (
|
|
<MailX className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<Label htmlFor="send-invitation" className="text-sm font-medium cursor-pointer">
|
|
Send platform invitation immediately
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{sendInvitation
|
|
? 'Members will receive an email invitation to create their account'
|
|
: 'Members will be created without notification — you can send invitations later from the Members page'}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
id="send-invitation"
|
|
checked={sendInvitation}
|
|
onCheckedChange={setSendInvitation}
|
|
/>
|
|
</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>
|
|
|
|
{!sendInvitation && (
|
|
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
|
|
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium">No invitations will be sent</p>
|
|
<p className="text-sm opacity-80">
|
|
Members will be created with “Not Invited” status. You can send invitations later from the Members page.
|
|
</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" />
|
|
) : sendInvitation ? (
|
|
<Mail className="mr-2 h-4 w-4" />
|
|
) : (
|
|
<Users className="mr-2 h-4 w-4" />
|
|
)}
|
|
{sendInvitation ? 'Create & Invite' : '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">
|
|
{sendInvitation ? 'Creating members and sending invitations...' : '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">
|
|
{result?.invitationSent ? 'Members Created & Invited!' : 'Members Created!'}
|
|
</p>
|
|
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
|
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
|
{result?.invitationSent ? 'created and invited' : 'created'}.
|
|
{result?.skipped
|
|
? ` ${result.skipped} skipped (already exist).`
|
|
: ''}
|
|
{result?.assignmentsCreated && result.assignmentsCreated > 0
|
|
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
|
|
: ''}
|
|
{!result?.invitationSent && (
|
|
<span className="block mt-1">
|
|
You can send invitations from the Members page when ready.
|
|
</span>
|
|
)}
|
|
</p>
|
|
<div className="mt-6 flex gap-3">
|
|
<Button variant="outline" asChild>
|
|
<Link href="/admin/members">View Members</Link>
|
|
</Button>
|
|
<Button onClick={resetForm}>Add 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>
|
|
)
|
|
}
|