From b6ba5d7145c61d32949fa8deadc71134b2505f01 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 13:29:56 +0100 Subject: [PATCH] feat: member profile pages with clickable links from all member lists - Member detail page (/admin/members/[id]) now shows: - Profile details card (nationality, country, institution, bio) - Team memberships / projects with links to project pages - Jury groups with role (Chair/Member/Observer) - All roles including Applicant, Award Master, Audience in role selector - Project detail page team members now show: - Nationality, institution, country inline - Names are clickable links to member profile pages - Members list: names are clickable links to profile pages (all tabs) - Applicants tab: added nationality and institution columns - Backend: user.get includes teamMemberships and juryGroupMemberships - Backend: project.getFullDetail includes nationality/country/institution Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/members/[id]/page.tsx | 127 ++++++++++++++++++- src/app/(admin)/admin/projects/[id]/page.tsx | 12 +- src/components/admin/members-content.tsx | 26 ++-- src/server/routers/project.ts | 2 +- src/server/routers/user.ts | 16 ++- 5 files changed, 168 insertions(+), 15 deletions(-) diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index b83fc42..64d0e58 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -60,6 +60,11 @@ import { Eye, ThumbsUp, ThumbsDown, + Globe, + Building2, + Flag, + FileText, + FolderOpen, } from 'lucide-react' export default function MemberDetailPage() { @@ -116,7 +121,7 @@ export default function MemberDetailPage() { id: userId, email: email || undefined, name: name || null, - role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', + role: role as 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'AWARD_MASTER' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE', status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED', expertiseTags, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, @@ -247,6 +252,123 @@ export default function MemberDetailPage() { + {/* Profile Details (read-only) */} + {(user.nationality || user.country || user.institution || user.bio) && ( + + + + + Profile Details + + Information provided during onboarding + + +
+ {user.nationality && ( +
+ +
+

Nationality

+

{user.nationality}

+
+
+ )} + {user.country && ( +
+ +
+

Country of Residence

+

{user.country}

+
+
+ )} + {user.institution && ( +
+ +
+

Institution / Organization

+

{user.institution}

+
+
+ )} + {user.bio && ( +
+ +
+

Bio

+

{user.bio}

+
+
+ )} +
+
+
+ )} + + {/* Team Memberships / Projects */} + {user.teamMemberships && user.teamMemberships.length > 0 && ( + + + + + Projects ({user.teamMemberships.length}) + + + +
+ {user.teamMemberships.map((tm) => ( + +
+

{tm.project.title}

+ {tm.project.teamName && ( +

{tm.project.teamName}

+ )} +
+
+ {tm.project.competitionCategory && ( + + {tm.project.competitionCategory.replace('_', ' ')} + + )} + + {tm.role === 'LEAD' ? 'Lead' : tm.role === 'ADVISOR' ? 'Advisor' : 'Member'} + +
+ + ))} +
+
+
+ )} + + {/* Jury Groups (for jury members) */} + {user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && ( + + + + + Jury Groups ({user.juryGroupMemberships.length}) + + + +
+ {user.juryGroupMemberships.map((m: { id: string; role: string; juryGroup: { id: string; name: string } }) => ( + + {m.juryGroup.name} + + ({m.role === 'CHAIR' ? 'Chair' : m.role === 'OBSERVER' ? 'Observer' : 'Member'}) + + + ))} +
+
+
+ )} +
{/* Basic Info */} @@ -302,6 +424,9 @@ export default function MemberDetailPage() { Jury Member Mentor Observer + Applicant + Award Master + Audience
diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index bcfff41..8cc1cce 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -513,10 +513,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {project.teamMembers && project.teamMembers.length > 0 ? (
- {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => { + {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => { const isLastLead = member.role === 'LEAD' && project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1 + const details = [member.user.nationality, member.user.institution, member.user.country].filter(Boolean) return (
{member.role === 'LEAD' ? ( @@ -528,9 +529,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )}
-

+ {member.user.name || 'Unnamed'} -

+ {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} @@ -541,6 +542,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {member.title && (

{member.title}

)} + {details.length > 0 && ( +

+ {details.join(' ยท ')} +

+ )}
diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index 64d7b71..4ea2de0 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -354,19 +354,19 @@ export function MembersContent() { /> -
+ ).avatarUrl as string | undefined} size="sm" />
-

{user.name || 'Unnamed'}

+

{user.name || 'Unnamed'}

{user.email}

-
+
@@ -460,14 +460,14 @@ export function MembersContent() { avatarUrl={(user as Record).avatarUrl as string | undefined} size="md" /> -
- + + {user.name || 'Unnamed'} {user.email} -
+
@@ -695,6 +695,8 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: Applicant Project + Nationality + Institution Status Last Login @@ -709,10 +711,10 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: /> -
-

{user.name || 'Unnamed'}

+ +

{user.name || 'Unnamed'}

{user.email}

-
+
{user.projectName ? ( @@ -721,6 +723,12 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: - )} + + {user.nationality || -} + + + {user.institution || -} +
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 2bf4ed0..c092deb 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1207,7 +1207,7 @@ export const projectRouter = router({ teamMembers: { include: { user: { - select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true }, + select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true, nationality: true, country: true, institution: true }, }, }, orderBy: { joinedAt: 'asc' }, diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 5562910..220935e 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -351,6 +351,20 @@ export const userRouter = router({ _count: { select: { assignments: true, mentorAssignments: true }, }, + teamMemberships: { + include: { + project: { + select: { id: true, title: true, teamName: true, competitionCategory: true }, + }, + }, + }, + juryGroupMemberships: { + include: { + juryGroup: { + select: { id: true, name: true }, + }, + }, + }, }, }) const avatarUrl = await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider) @@ -447,7 +461,7 @@ export const userRouter = router({ id: z.string(), email: z.string().email().optional(), name: z.string().optional().nullable(), - role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), + role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'AUDIENCE']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(),