feat: member profile pages with clickable links from all member lists
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:29:56 +01:00
parent c6d0f90038
commit b6ba5d7145
5 changed files with 168 additions and 15 deletions

View File

@@ -60,6 +60,11 @@ import {
Eye, Eye,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
Globe,
Building2,
Flag,
FileText,
FolderOpen,
} from 'lucide-react' } from 'lucide-react'
export default function MemberDetailPage() { export default function MemberDetailPage() {
@@ -116,7 +121,7 @@ export default function MemberDetailPage() {
id: userId, id: userId,
email: email || undefined, email: email || undefined,
name: name || null, 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', status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags, expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
@@ -247,6 +252,123 @@ export default function MemberDetailPage() {
</TabsList> </TabsList>
<TabsContent value="profile" className="space-y-6"> <TabsContent value="profile" className="space-y-6">
{/* Profile Details (read-only) */}
{(user.nationality || user.country || user.institution || user.bio) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Profile Details
</CardTitle>
<CardDescription>Information provided during onboarding</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{user.nationality && (
<div className="flex items-start gap-2">
<Flag className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
<p className="text-sm">{user.nationality}</p>
</div>
</div>
)}
{user.country && (
<div className="flex items-start gap-2">
<Globe className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
<p className="text-sm">{user.country}</p>
</div>
</div>
)}
{user.institution && (
<div className="flex items-start gap-2">
<Building2 className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Institution / Organization</p>
<p className="text-sm">{user.institution}</p>
</div>
</div>
)}
{user.bio && (
<div className="flex items-start gap-2 sm:col-span-2">
<FileText className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Bio</p>
<p className="text-sm whitespace-pre-line">{user.bio}</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Team Memberships / Projects */}
{user.teamMemberships && user.teamMemberships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FolderOpen className="h-5 w-5" />
Projects ({user.teamMemberships.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2">
{user.teamMemberships.map((tm) => (
<Link
key={tm.id}
href={`/admin/projects/${tm.project.id}`}
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{tm.project.title}</p>
{tm.project.teamName && (
<p className="text-xs text-muted-foreground">{tm.project.teamName}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{tm.project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{tm.project.competitionCategory.replace('_', ' ')}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{tm.role === 'LEAD' ? 'Lead' : tm.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Jury Groups (for jury members) */}
{user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Jury Groups ({user.juryGroupMemberships.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.juryGroupMemberships.map((m: { id: string; role: string; juryGroup: { id: string; name: string } }) => (
<Badge key={m.id} variant="outline" className="text-sm py-1 px-3">
{m.juryGroup.name}
<span className="ml-1.5 text-xs text-muted-foreground">
({m.role === 'CHAIR' ? 'Chair' : m.role === 'OBSERVER' ? 'Observer' : 'Member'})
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Basic Info */} {/* Basic Info */}
<Card> <Card>
@@ -302,6 +424,9 @@ export default function MemberDetailPage() {
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem> <SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem> <SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem> <SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -513,10 +513,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardContent> <CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? ( {project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{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 = const isLastLead =
member.role === 'LEAD' && member.role === 'LEAD' &&
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1 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 ( return (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border"> <div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? ( {member.role === 'LEAD' ? (
@@ -528,9 +529,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium text-sm truncate"> <Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
{member.user.name || 'Unnamed'} {member.user.name || 'Unnamed'}
</p> </Link>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge> </Badge>
@@ -541,6 +542,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{member.title && ( {member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p> <p className="text-xs text-muted-foreground">{member.title}</p>
)} )}
{details.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{details.join(' · ')}
</p>
)}
</div> </div>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>

View File

@@ -354,19 +354,19 @@ export function MembersContent() {
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <Link href={`/admin/members/${user.id}`} className="flex items-center gap-3 hover:opacity-80">
<UserAvatar <UserAvatar
user={user} user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined} avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
size="sm" size="sm"
/> />
<div> <div>
<p className="font-medium">{user.name || 'Unnamed'}</p> <p className="font-medium hover:underline">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{user.email} {user.email}
</p> </p>
</div> </div>
</div> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -460,14 +460,14 @@ export function MembersContent() {
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined} avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
size="md" size="md"
/> />
<div> <Link href={`/admin/members/${user.id}`}>
<CardTitle className="text-base"> <CardTitle className="text-base hover:underline">
{user.name || 'Unnamed'} {user.name || 'Unnamed'}
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
{user.email} {user.email}
</CardDescription> </CardDescription>
</div> </Link>
</div> </div>
<div className="flex flex-col items-end gap-1.5"> <div className="flex flex-col items-end gap-1.5">
<Badge variant={statusColors[user.status] || 'secondary'}> <Badge variant={statusColors[user.status] || 'secondary'}>
@@ -695,6 +695,8 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
</TableHead> </TableHead>
<TableHead>Applicant</TableHead> <TableHead>Applicant</TableHead>
<TableHead>Project</TableHead> <TableHead>Project</TableHead>
<TableHead>Nationality</TableHead>
<TableHead>Institution</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Last Login</TableHead> <TableHead>Last Login</TableHead>
</TableRow> </TableRow>
@@ -709,10 +711,10 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<div> <Link href={`/admin/members/${user.id}`} className="block">
<p className="font-medium">{user.name || 'Unnamed'}</p> <p className="font-medium hover:underline">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{user.email}</p> <p className="text-sm text-muted-foreground">{user.email}</p>
</div> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
{user.projectName ? ( {user.projectName ? (
@@ -721,6 +723,12 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
<span className="text-sm text-muted-foreground">-</span> <span className="text-sm text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell>
<span className="text-sm">{user.nationality || <span className="text-muted-foreground">-</span>}</span>
</TableCell>
<TableCell>
<span className="text-sm">{user.institution || <span className="text-muted-foreground">-</span>}</span>
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={statusColors[user.status] || 'secondary'}> <Badge variant={statusColors[user.status] || 'secondary'}>

View File

@@ -1207,7 +1207,7 @@ export const projectRouter = router({
teamMembers: { teamMembers: {
include: { include: {
user: { 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' }, orderBy: { joinedAt: 'asc' },

View File

@@ -351,6 +351,20 @@ export const userRouter = router({
_count: { _count: {
select: { assignments: true, mentorAssignments: true }, 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) const avatarUrl = await getUserAvatarUrl(user.profileImageKey, user.profileImageProvider)
@@ -447,7 +461,7 @@ export const userRouter = router({
id: z.string(), id: z.string(),
email: z.string().email().optional(), email: z.string().email().optional(),
name: z.string().optional().nullable(), 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(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
maxAssignments: z.number().int().min(1).max(100).optional().nullable(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(),