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
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:
@@ -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() {
|
||||
</TabsList>
|
||||
|
||||
<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">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
@@ -302,6 +424,9 @@ export default function MemberDetailPage() {
|
||||
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
|
||||
<SelectItem value="MENTOR">Mentor</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
<SelectItem value="APPLICANT">Applicant</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER">Award Master</SelectItem>
|
||||
<SelectItem value="AUDIENCE">Audience</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -513,10 +513,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<CardContent>
|
||||
{project.teamMembers && project.teamMembers.length > 0 ? (
|
||||
<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 =
|
||||
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 (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
@@ -528,9 +529,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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'}
|
||||
</p>
|
||||
</Link>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
@@ -541,6 +542,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
{member.title && (
|
||||
<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>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
||||
@@ -354,19 +354,19 @@ export function MembersContent() {
|
||||
/>
|
||||
</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
|
||||
user={user}
|
||||
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||
size="sm"
|
||||
/>
|
||||
<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">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -460,14 +460,14 @@ export function MembersContent() {
|
||||
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/admin/members/${user.id}`}>
|
||||
<CardTitle className="text-base hover:underline">
|
||||
{user.name || 'Unnamed'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{user.email}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
@@ -695,6 +695,8 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
|
||||
</TableHead>
|
||||
<TableHead>Applicant</TableHead>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Nationality</TableHead>
|
||||
<TableHead>Institution</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
</TableRow>
|
||||
@@ -709,10 +711,10 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{user.name || 'Unnamed'}</p>
|
||||
<Link href={`/admin/members/${user.id}`} className="block">
|
||||
<p className="font-medium hover:underline">{user.name || 'Unnamed'}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.projectName ? (
|
||||
@@ -721,6 +723,12 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search:
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user