- ExpertiseSelect now fetches tags from database with category grouping - Tags set by admin during invitation are locked and cannot be removed - Onboarding merges user-selected tags with admin-preset tags - MENTOR role now goes through onboarding flow - Added migration for Round.entryNotificationType column - Added seed script with ~90 comprehensive expertise tags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { cn } from '@/lib/utils'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Check, X, Lock, Search, Loader2, ChevronDown, ChevronRight } from 'lucide-react'
|
|
|
|
interface ExpertiseSelectProps {
|
|
value: string[]
|
|
onChange: (tags: string[]) => void
|
|
maxTags?: number
|
|
disabled?: boolean
|
|
className?: string
|
|
lockedTags?: string[] // Tags set by admin that cannot be removed
|
|
}
|
|
|
|
export function ExpertiseSelect({
|
|
value,
|
|
onChange,
|
|
maxTags = 10,
|
|
disabled = false,
|
|
className,
|
|
lockedTags = [],
|
|
}: ExpertiseSelectProps) {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
|
|
|
// Fetch tags from database
|
|
const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true })
|
|
const tags = data?.tags || []
|
|
|
|
// Group tags by category
|
|
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])
|
|
|
|
// Filter tags by search query
|
|
const filteredTagsByCategory = useMemo(() => {
|
|
if (!searchQuery.trim()) return tagsByCategory
|
|
|
|
const query = searchQuery.toLowerCase()
|
|
const filtered: Record<string, typeof tags> = {}
|
|
|
|
for (const [category, categoryTags] of Object.entries(tagsByCategory)) {
|
|
const matchingTags = categoryTags.filter(
|
|
(tag) =>
|
|
tag.name.toLowerCase().includes(query) ||
|
|
tag.description?.toLowerCase().includes(query) ||
|
|
category.toLowerCase().includes(query)
|
|
)
|
|
if (matchingTags.length > 0) {
|
|
filtered[category] = matchingTags
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}, [tagsByCategory, searchQuery])
|
|
|
|
// Check if a tag is locked
|
|
const isTagLocked = (tagName: string) => lockedTags.includes(tagName)
|
|
|
|
const handleToggle = (name: string) => {
|
|
if (disabled || isTagLocked(name)) return
|
|
|
|
if (value.includes(name)) {
|
|
onChange(value.filter((t) => t !== name))
|
|
} else {
|
|
// Don't count locked tags against the max
|
|
const selectableTags = value.filter((t) => !isTagLocked(t))
|
|
if (maxTags && selectableTags.length >= maxTags) return
|
|
onChange([...value, name])
|
|
}
|
|
}
|
|
|
|
const handleRemove = (name: string) => {
|
|
if (disabled || isTagLocked(name)) return
|
|
onChange(value.filter((t) => t !== name))
|
|
}
|
|
|
|
const toggleCategory = (category: string) => {
|
|
setExpandedCategories((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(category)) {
|
|
next.delete(category)
|
|
} else {
|
|
next.add(category)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const getTag = (name: string) => tags.find((t) => t.name === name)
|
|
|
|
// Count user-selected tags (not including locked)
|
|
const userSelectedCount = value.filter((t) => !isTagLocked(t)).length
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className={cn('flex items-center justify-center py-8', className)}>
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-muted-foreground">Loading expertise areas...</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (tags.length === 0) {
|
|
return (
|
|
<div className={cn('text-center py-8 text-muted-foreground', className)}>
|
|
No expertise areas available. Please contact an administrator.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
{/* Search input */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search expertise areas..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Selected tags at the top */}
|
|
{value.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{value.map((name) => {
|
|
const tag = getTag(name)
|
|
const isLocked = isTagLocked(name)
|
|
return (
|
|
<Badge
|
|
key={name}
|
|
variant="secondary"
|
|
className={cn(
|
|
'gap-1.5 py-1 px-2 text-sm',
|
|
isLocked && 'opacity-75'
|
|
)}
|
|
style={{
|
|
backgroundColor: tag?.color ? `${tag.color}15` : undefined,
|
|
borderColor: tag?.color || undefined,
|
|
color: tag?.color || undefined,
|
|
}}
|
|
>
|
|
{isLocked && <Lock className="h-3 w-3 mr-0.5" />}
|
|
{name}
|
|
{!disabled && !isLocked && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemove(name)}
|
|
className="ml-0.5 rounded-full p-0.5 hover:bg-black/10 transition-colors"
|
|
aria-label={`Remove ${name}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Locked tags notice */}
|
|
{lockedTags.length > 0 && (
|
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Lock className="h-3 w-3" />
|
|
Tags with a lock icon were pre-selected by your administrator
|
|
</p>
|
|
)}
|
|
|
|
{/* Categories with expandable tag lists */}
|
|
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
|
{Object.entries(filteredTagsByCategory)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([category, categoryTags]) => {
|
|
const isExpanded = expandedCategories.has(category) || searchQuery.trim() !== ''
|
|
const selectedInCategory = categoryTags.filter((t) => value.includes(t.name)).length
|
|
|
|
return (
|
|
<div key={category} className="border rounded-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCategory(category)}
|
|
className="w-full flex items-center justify-between px-3 py-2 bg-muted/50 hover:bg-muted transition-colors text-sm font-medium"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
{category}
|
|
</span>
|
|
{selectedInCategory > 0 && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{selectedInCategory} selected
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1 p-2">
|
|
{categoryTags.map((tag) => {
|
|
const isSelected = value.includes(tag.name)
|
|
const isLocked = isTagLocked(tag.name)
|
|
const isDisabledOption =
|
|
disabled ||
|
|
(isLocked && isSelected) ||
|
|
(!isSelected && !isLocked && userSelectedCount >= maxTags)
|
|
|
|
return (
|
|
<Button
|
|
key={tag.id}
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={isDisabledOption && !isSelected}
|
|
onClick={() => handleToggle(tag.name)}
|
|
className={cn(
|
|
'justify-start h-auto py-1.5 px-2 text-left font-normal transition-all',
|
|
isLocked && 'cursor-not-allowed',
|
|
isDisabledOption && !isSelected && 'opacity-50'
|
|
)}
|
|
style={{
|
|
borderColor: isSelected ? tag.color || undefined : undefined,
|
|
backgroundColor: isSelected ? `${tag.color}10` : undefined,
|
|
// Use box-shadow for ring effect with dynamic color
|
|
boxShadow: isSelected && tag.color ? `0 0 0 2px ${tag.color}30` : undefined,
|
|
}}
|
|
title={tag.description || tag.name}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'h-3.5 w-3.5 rounded border-2 mr-2 flex items-center justify-center transition-colors shrink-0',
|
|
isSelected
|
|
? 'border-current bg-current'
|
|
: 'border-muted-foreground/30'
|
|
)}
|
|
style={{
|
|
borderColor: isSelected ? tag.color || undefined : undefined,
|
|
backgroundColor: isSelected ? tag.color || undefined : undefined,
|
|
}}
|
|
>
|
|
{isSelected &&
|
|
(isLocked ? (
|
|
<Lock className="h-2 w-2 text-white" />
|
|
) : (
|
|
<Check className="h-2 w-2 text-white" />
|
|
))}
|
|
</div>
|
|
<span className="text-xs truncate">{tag.name}</span>
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{Object.keys(filteredTagsByCategory).length === 0 && searchQuery && (
|
|
<p className="text-center text-sm text-muted-foreground py-4">
|
|
No expertise areas match your search.
|
|
</p>
|
|
)}
|
|
|
|
{/* Counter */}
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{userSelectedCount} of {maxTags} selected
|
|
{lockedTags.length > 0 && ` (+ ${lockedTags.length} pre-selected)`}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|