UI polish: grouped dropdowns, analytics readability, invite tag picker
Build and Push Docker Image / build (push) Successful in 9m56s
Details
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:
parent
4830c0638c
commit
98fe658c33
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue