UI polish: grouped dropdowns, analytics readability, invite tag picker

- Messages: group users by role in recipient dropdown (SelectGroup)
- Analytics: full country names via Intl.DisplayNames, format SNAKE_CASE
  labels to Title Case, custom tooltips, increased font sizes
- Invite: replace free-text tag input with grouped dropdown from DB tags
  using Command/Popover, showing tags organized by category with colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 00:06:47 +01:00
parent 4830c0638c
commit 98fe658c33
3 changed files with 283 additions and 156 deletions

View File

@@ -37,6 +37,19 @@ import {
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 {
ArrowLeft,
ArrowRight,
@@ -50,6 +63,8 @@ import {
UserPlus,
FolderKanban,
ChevronDown,
Check,
Tags,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -67,7 +82,6 @@ interface MemberRow {
email: string
role: Role
expertiseTags: string[]
tagInput: string
assignments: Assignment[]
}
@@ -96,30 +110,144 @@ function nextRowId(): string {
}
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '', assignments: [] }
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], assignments: [] }
}
// Common expertise tags for suggestions
const SUGGESTED_TAGS = [
'Marine Biology',
'Ocean Conservation',
'Coral Reef Restoration',
'Sustainable Fisheries',
'Marine Policy',
'Ocean Technology',
'Climate Science',
'Biodiversity',
'Blue Economy',
'Coastal Management',
'Oceanography',
'Marine Pollution',
'Plastic Reduction',
'Renewable Energy',
'Business Development',
'Impact Investment',
'Social Entrepreneurship',
'Startup Mentoring',
]
/** 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')
@@ -198,7 +326,7 @@ export default function MemberInvitePage() {
prev.map((r) => {
if (r.id !== id) return r
if (r.expertiseTags.includes(trimmed)) return r
return { ...r, expertiseTags: [...r.expertiseTags, trimmed], tagInput: '' }
return { ...r, expertiseTags: [...r.expertiseTags, trimmed] }
})
)
}
@@ -213,15 +341,6 @@ export default function MemberInvitePage() {
)
}
// Get suggestions that haven't been added yet for a specific row
const getSuggestionsForRow = (row: MemberRow) => {
return SUGGESTED_TAGS.filter(
(tag) =>
!row.expertiseTags.includes(tag) &&
tag.toLowerCase().includes(row.tagInput.toLowerCase())
).slice(0, 5)
}
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return
@@ -518,87 +637,11 @@ export default function MemberInvitePage() {
</div>
{/* Per-member expertise tags */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Expertise Tags (optional)
</Label>
<div className="relative">
<Input
placeholder="Type tag and press Enter or comma..."
value={row.tagInput}
onChange={(e) =>
updateRow(row.id, 'tagInput', e.target.value)
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTagToRow(row.id, row.tagInput)
}
}}
/>
</div>
{/* Tag suggestions */}
{row.tagInput && getSuggestionsForRow(row).length > 0 && (
<div className="flex flex-wrap gap-1">
{getSuggestionsForRow(row).map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => addTagToRow(row.id, suggestion)}
>
+ {suggestion}
</Button>
))}
</div>
)}
{/* Quick suggestions when empty */}
{!row.tagInput && row.expertiseTags.length === 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-muted-foreground mr-1">
Suggestions:
</span>
{SUGGESTED_TAGS.slice(0, 5).map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs px-2"
onClick={() => addTagToRow(row.id, suggestion)}
>
+ {suggestion}
</Button>
))}
</div>
)}
{/* Added tags */}
{row.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{row.expertiseTags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="gap-1 pr-1"
>
{tag}
<button
type="button"
onClick={() => removeTagFromRow(row.id, tag)}
className="ml-1 hover:text-destructive rounded-full"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<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' && selectedRoundId && (