MOPC-App/src/components/admin/jury/add-member-dialog.tsx

347 lines
12 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Search, UserPlus, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface AddMemberDialogProps {
juryGroupId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [tab, setTab] = useState<'search' | 'invite'>('search')
// Search existing user state
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('')
// Invite new user state
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('')
const [inviteExpertise, setInviteExpertise] = useState('')
const utils = trpc.useUtils()
const { data: userResponse, isLoading: isSearching } = trpc.user.list.useQuery(
{ search: searchQuery, perPage: 20 },
{ enabled: searchQuery.length > 0 }
)
const users = userResponse?.users || []
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
onOpenChange(false)
resetForm()
},
onError: (err) => {
toast.error(err.message)
},
})
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
onSuccess: (newUser) => {
// Immediately add the newly created user to the jury group
addMember({
juryGroupId,
userId: newUser.id,
role: 'MEMBER',
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
})
// Send invitation email
sendInvitation({ userId: newUser.id, juryGroupId })
},
onError: (err) => {
toast.error(err.message)
},
})
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`)
utils.user.list.invalidate()
},
onError: (err) => {
// Don't block — user was created and added, just invitation failed
toast.error(`Member added but invitation email failed: ${err.message}`)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setMaxAssignments('')
setCapMode('')
setInviteName('')
setInviteEmail('')
setInviteMaxAssignments('')
setInviteCapMode('')
setInviteExpertise('')
}
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
toast.error('Please select a user')
return
}
addMember({
juryGroupId,
userId: selectedUserId,
role: 'MEMBER',
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
})
}
const handleInviteSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!inviteEmail.trim()) {
toast.error('Please enter an email address')
return
}
const expertiseTags = inviteExpertise
.split(',')
.map((t) => t.trim())
.filter(Boolean)
createUser({
email: inviteEmail.trim(),
name: inviteName.trim() || undefined,
role: 'JURY_MEMBER',
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
})
}
const isPending = isAdding || isCreating
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for an existing user or invite a new juror to the platform
</DialogDescription>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="search" className="flex items-center gap-2">
<Search className="h-3.5 w-3.5" />
Search Existing
</TabsTrigger>
<TabsTrigger value="invite" className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5" />
Invite New
</TabsTrigger>
</TabsList>
{/* Search existing user tab */}
<TabsContent value="search">
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{isSearching && (
<p className="text-sm text-muted-foreground">Searching...</p>
)}
{users && users.length > 0 && (
<div className="max-h-40 overflow-y-auto border rounded-md">
{users.map((user) => (
<button
key={user.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-accent ${
selectedUserId === user.id ? 'bg-accent' : ''
}`}
onClick={() => {
setSelectedUserId(user.id)
setSearchQuery(user.email)
}}
>
<div className="font-medium">{user.name || 'Unnamed User'}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
<SelectTrigger id="capMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
<Input
id="maxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isAdding ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</TabsContent>
{/* Invite new user tab */}
<TabsContent value="invite">
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteName">Full Name</Label>
<Input
id="inviteName"
placeholder="Jane Doe"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteEmail">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="inviteEmail"
type="email"
placeholder="jane@example.com"
required
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
<SelectTrigger id="inviteCapMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
<Input
id="inviteMaxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={inviteMaxAssignments}
onChange={(e) => setInviteMaxAssignments(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
<Input
id="inviteExpertise"
placeholder="marine biology, policy, finance"
value={inviteExpertise}
onChange={(e) => setInviteExpertise(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Comma-separated tags for smart assignment matching
</p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
<UserPlus className="mr-1 inline h-3 w-3" />
This will create a new user account and send an invitation email to join the platform as a jury member.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
</Button>
</DialogFooter>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}