Files
MOPC-Portal/src/components/shared/expertise-select.tsx

289 lines
10 KiB
TypeScript
Raw Normal View History

'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>
)
}