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,
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user