UI polish: grouped dropdowns, analytics readability, invite tag picker
Build and Push Docker Image / build (push) Successful in 9m56s Details

- 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:
Matt 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)
}
}}
<TagPicker
selectedTags={row.expertiseTags}
onAdd={(tag) => addTagToRow(row.id, tag)}
onRemove={(tag) => removeTagFromRow(row.id, tag)}
/>
</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>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && (

View File

@ -21,7 +21,9 @@ import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
@ -321,11 +323,44 @@ export default function MessagesPage() {
<SelectValue placeholder="Choose a user..." />
</SelectTrigger>
<SelectContent>
{(users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users?.map((u) => (
{(() => {
const userList = (users as { users: Array<{ id: string; name: string | null; email: string; role: string }> } | undefined)?.users
if (!userList) return null
const grouped = userList.reduce<Record<string, typeof userList>>((acc, u) => {
const role = u.role || 'OTHER'
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
}, {})
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admins',
PROGRAM_ADMIN: 'Program Admins',
JURY_MEMBER: 'Jury Members',
MENTOR: 'Mentors',
OBSERVER: 'Observers',
APPLICANT: 'Applicants',
OTHER: 'Other',
}
const roleOrder = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'OTHER']
return roleOrder
.filter((role) => grouped[role]?.length)
.map((role) => (
<SelectGroup key={role}>
<SelectLabel>{roleLabels[role] || role}</SelectLabel>
{grouped[role]
.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email))
.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))}
</SelectGroup>
))
})()}
</SelectContent>
</Select>
</div>

View File

@ -34,6 +34,53 @@ const PIE_COLORS = [
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
]
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string {
if (code === 'Others') return 'Others'
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
return displayNames.of(code.toUpperCase()) || code
} catch {
return code
}
}
/** Convert SCREAMING_SNAKE_CASE to Title Case */
function formatLabel(value: string): string {
if (!value) return value
return value
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Custom tooltip for the pie chart */
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
if (!active || !payload?.length) return null
const d = payload[0].payload
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{getCountryName(d.country)}</p>
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
</div>
)
}
/** Custom tooltip for bar charts */
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
if (!active || !payload?.length) return null
const entry = payload[0]
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
const dataPoint = rawPayload.payload
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{labelFormatter(rawLabel)}</p>
<p className="text-muted-foreground">{entry.value} projects</p>
</div>
)
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) {
return (
@ -56,6 +103,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
}]
: topCountries
// Pre-format category and ocean issue data for display
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
...c,
category: formatLabel(c.category),
}))
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
...o,
issue: formatLabel(o.issue),
}))
return (
<div className="space-y-6">
{/* Summary */}
@ -93,7 +151,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Geographic Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@ -105,18 +163,20 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
paddingAngle={2}
dataKey="count"
nameKey="country"
label
label={((props: unknown) => {
const p = props as { country: string; percentage: number }
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
}) as unknown as boolean}
fontSize={13}
>
{countryPieData.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
<Tooltip content={<CountryTooltip />} />
<Legend
formatter={(value: string) => getCountryName(value)}
wrapperStyle={{ fontSize: '13px' }}
/>
</PieChart>
</ResponsiveContainer>
@ -130,29 +190,23 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Competition Categories</CardTitle>
</CardHeader>
<CardContent>
{data.byCategory.length > 0 ? (
<div className="h-[350px]">
{formattedCategories.length > 0 ? (
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data.byCategory.slice(0, 10)}
data={formattedCategories}
layout="vertical"
margin={{ top: 5, right: 30, bottom: 5, left: 100 }}
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" />
<XAxis type="number" tick={{ fontSize: 13 }} />
<YAxis
type="category"
dataKey="category"
width={90}
tick={{ fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
width={110}
tick={{ fontSize: 13 }}
/>
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
@ -165,34 +219,29 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
</div>
{/* Ocean Issues */}
{data.byOceanIssue.length > 0 && (
{formattedOceanIssues.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
<div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data.byOceanIssue.slice(0, 15)}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
data={formattedOceanIssues}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="issue"
angle={-35}
textAnchor="end"
height={80}
tick={{ fontSize: 11 }}
/>
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
height={100}
tick={{ fontSize: 12 }}
interval={0}
/>
<YAxis tick={{ fontSize: 13 }} />
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
@ -215,7 +264,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
variant="secondary"
className="text-sm"
style={{
fontSize: `${Math.max(0.7, Math.min(1.4, 0.7 + tag.percentage / 20))}rem`,
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
}}
>
{tag.tag} ({tag.count})