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, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@ -50,6 +63,8 @@ import {
UserPlus, UserPlus,
FolderKanban, FolderKanban,
ChevronDown, ChevronDown,
Check,
Tags,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -67,7 +82,6 @@ interface MemberRow {
email: string email: string
role: Role role: Role
expertiseTags: string[] expertiseTags: string[]
tagInput: string
assignments: Assignment[] assignments: Assignment[]
} }
@ -96,30 +110,144 @@ function nextRowId(): string {
} }
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow { 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 /** Inline tag picker with grouped dropdown from database tags */
const SUGGESTED_TAGS = [ function TagPicker({
'Marine Biology', selectedTags,
'Ocean Conservation', onAdd,
'Coral Reef Restoration', onRemove,
'Sustainable Fisheries', }: {
'Marine Policy', selectedTags: string[]
'Ocean Technology', onAdd: (tag: string) => void
'Climate Science', onRemove: (tag: string) => void
'Biodiversity', }) {
'Blue Economy', const [open, setOpen] = useState(false)
'Coastal Management', const { data, isLoading } = trpc.tag.list.useQuery({ isActive: true })
'Oceanography', const tags = data?.tags || []
'Marine Pollution',
'Plastic Reduction', const tagsByCategory = useMemo(() => {
'Renewable Energy', const grouped: Record<string, typeof tags> = {}
'Business Development', for (const tag of tags) {
'Impact Investment', const category = tag.category || 'Other'
'Social Entrepreneurship', if (!grouped[category]) grouped[category] = []
'Startup Mentoring', 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() { export default function MemberInvitePage() {
const [step, setStep] = useState<Step>('input') const [step, setStep] = useState<Step>('input')
@ -198,7 +326,7 @@ export default function MemberInvitePage() {
prev.map((r) => { prev.map((r) => {
if (r.id !== id) return r if (r.id !== id) return r
if (r.expertiseTags.includes(trimmed)) 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 // Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => { const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return if (!selectedRoundId) return
@ -518,87 +637,11 @@ export default function MemberInvitePage() {
</div> </div>
{/* Per-member expertise tags */} {/* Per-member expertise tags */}
<div className="space-y-2"> <TagPicker
<Label className="text-xs text-muted-foreground"> selectedTags={row.expertiseTags}
Expertise Tags (optional) onAdd={(tag) => addTagToRow(row.id, tag)}
</Label> onRemove={(tag) => removeTagFromRow(row.id, tag)}
<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>
{/* Per-member project pre-assignment (only for jury members) */} {/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && ( {row.role === 'JURY_MEMBER' && selectedRoundId && (

View File

@ -21,7 +21,9 @@ import { Switch } from '@/components/ui/switch'
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
@ -321,11 +323,44 @@ export default function MessagesPage() {
<SelectValue placeholder="Choose a user..." /> <SelectValue placeholder="Choose a user..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users?.map((u) => ( {(() => {
<SelectItem key={u.id} value={u.id}> const userList = (users as { users: Array<{ id: string; name: string | null; email: string; role: string }> } | undefined)?.users
{u.name || u.email} if (!userList) return null
</SelectItem>
))} 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> </SelectContent>
</Select> </Select>
</div> </div>

View File

@ -34,6 +34,53 @@ const PIE_COLORS = [
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1', '#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) { export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) { if (data.total === 0) {
return ( return (
@ -56,6 +103,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
}] }]
: topCountries : 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Summary */} {/* Summary */}
@ -93,7 +151,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Geographic Distribution</CardTitle> <CardTitle>Geographic Distribution</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[350px]"> <div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
@ -105,18 +163,20 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
paddingAngle={2} paddingAngle={2}
dataKey="count" dataKey="count"
nameKey="country" 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) => ( {countryPieData.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> <Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip content={<CountryTooltip />} />
contentStyle={{ <Legend
backgroundColor: 'hsl(var(--card))', formatter={(value: string) => getCountryName(value)}
border: '1px solid hsl(var(--border))', wrapperStyle={{ fontSize: '13px' }}
borderRadius: '6px',
}}
/> />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -130,29 +190,23 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Competition Categories</CardTitle> <CardTitle>Competition Categories</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{data.byCategory.length > 0 ? ( {formattedCategories.length > 0 ? (
<div className="h-[350px]"> <div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={data.byCategory.slice(0, 10)} data={formattedCategories}
layout="vertical" 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" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" /> <XAxis type="number" tick={{ fontSize: 13 }} />
<YAxis <YAxis
type="category" type="category"
dataKey="category" dataKey="category"
width={90} width={110}
tick={{ fontSize: 12 }} tick={{ fontSize: 13 }}
/> />
<Tooltip <Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} /> <Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -165,34 +219,29 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
</div> </div>
{/* Ocean Issues */} {/* Ocean Issues */}
{data.byOceanIssue.length > 0 && ( {formattedOceanIssues.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle> <CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[350px]"> <div className="h-[400px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={data.byOceanIssue.slice(0, 15)} data={formattedOceanIssues}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }} margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
> >
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis <XAxis
dataKey="issue" dataKey="issue"
angle={-35} angle={-35}
textAnchor="end" textAnchor="end"
height={80} height={100}
tick={{ fontSize: 11 }} tick={{ fontSize: 12 }}
/> interval={0}
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/> />
<YAxis tick={{ fontSize: 13 }} />
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} /> <Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -215,7 +264,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
variant="secondary" variant="secondary"
className="text-sm" className="text-sm"
style={{ 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}) {tag.tag} ({tag.count})