diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 54b2f64..03ffb3d 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -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 = {} + for (const tag of tags) { + const category = tag.category || 'Other' + if (!grouped[category]) grouped[category] = [] + grouped[category].push(tag) + } + return grouped + }, [tags]) + + return ( +
+ + + + + + + + + + + + {isLoading ? 'Loading tags...' : 'No tags found.'} + + {Object.entries(tagsByCategory) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, categoryTags]) => ( + + {categoryTags.map((tag) => { + const isSelected = selectedTags.includes(tag.name) + return ( + { + if (isSelected) { + onRemove(tag.name) + } else { + onAdd(tag.name) + } + }} + > +
+ {isSelected && } +
+ {tag.name} + {tag.color && ( + + )} +
+ ) + })} +
+ ))} +
+
+
+
+ + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tagName) => { + const tagData = tags.find((t) => t.name === tagName) + return ( + + {tagName} + + + ) + })} +
+ )} +
+ ) +} export default function MemberInvitePage() { const [step, setStep] = useState('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() { {/* Per-member expertise tags */} -
- -
- - updateRow(row.id, 'tagInput', e.target.value) - } - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault() - addTagToRow(row.id, row.tagInput) - } - }} - /> -
- - {/* Tag suggestions */} - {row.tagInput && getSuggestionsForRow(row).length > 0 && ( -
- {getSuggestionsForRow(row).map((suggestion) => ( - - ))} -
- )} - - {/* Quick suggestions when empty */} - {!row.tagInput && row.expertiseTags.length === 0 && ( -
- - Suggestions: - - {SUGGESTED_TAGS.slice(0, 5).map((suggestion) => ( - - ))} -
- )} - - {/* Added tags */} - {row.expertiseTags.length > 0 && ( -
- {row.expertiseTags.map((tag) => ( - - {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 && ( diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index a44a67a..017b9b3 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -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() { - {(users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users?.map((u) => ( - - {u.name || u.email} - - ))} + {(() => { + 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>((acc, u) => { + const role = u.role || 'OTHER' + if (!acc[role]) acc[role] = [] + acc[role].push(u) + return acc + }, {}) + + const roleLabels: Record = { + 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) => ( + + {roleLabels[role] || role} + {grouped[role] + .sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email)) + .map((u) => ( + + {u.name || u.email} + + ))} + + )) + })()} diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx index 8a92662..eccadeb 100644 --- a/src/components/charts/diversity-metrics.tsx +++ b/src/components/charts/diversity-metrics.tsx @@ -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 ( +
+

{getCountryName(d.country)}

+

{d.count} projects ({d.percentage.toFixed(1)}%)

+
+ ) +} + +/** 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 } + const dataPoint = rawPayload.payload + const rawLabel = (dataPoint.category || dataPoint.issue || '') as string + return ( +
+

{labelFormatter(rawLabel)}

+

{entry.value} projects

+
+ ) +} + 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 (
{/* Summary */} @@ -93,7 +151,7 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { Geographic Distribution -
+
{ + 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) => ( ))} - } /> + getCountryName(value)} + wrapperStyle={{ fontSize: '13px' }} /> @@ -130,29 +190,23 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { Competition Categories - {data.byCategory.length > 0 ? ( -
+ {formattedCategories.length > 0 ? ( +
- + - + v} />} /> @@ -165,34 +219,29 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
{/* Ocean Issues */} - {data.byOceanIssue.length > 0 && ( + {formattedOceanIssues.length > 0 && ( Ocean Issues Addressed -
+
- - + + v} />} /> @@ -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})