From a358e9940da074893571285e38e02ee3477b8cec Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 10:39:21 +0100 Subject: [PATCH] feat: revamp admin member detail page, observer dashboard round timeline - Member detail: tabs layout, impersonate button, icon-pill card headers, profile details grid, quick info sidebar, jury groups, mentor assignments - Observer dashboard: round timeline with special award support, round node cards, completion indicators - Analytics: include specialAwardId/Name in observer round overview Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/members/[id]/page.tsx | 839 ++++++++++-------- .../observer/observer-dashboard-content.tsx | 223 +++-- src/server/routers/analytics.ts | 4 + 3 files changed, 620 insertions(+), 446 deletions(-) diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index 6b13d81..e84f6b6 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' +import { directSessionUpdate } from '@/lib/session-update' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -64,8 +65,40 @@ import { Building2, FileText, FolderOpen, + LogIn, + Calendar, + Clock, } from 'lucide-react' import { getCountryName, getCountryFlag } from '@/lib/countries' +import { formatRelativeTime } from '@/lib/utils' + +function getRoleHomePath(role: string): string { + switch (role) { + case 'JURY_MEMBER': return '/jury' + case 'APPLICANT': return '/applicant' + case 'MENTOR': return '/mentor' + case 'OBSERVER': return '/observer' + default: return '/admin' + } +} + +const statusVariant: Record = { + ACTIVE: 'success', + SUSPENDED: 'destructive', + INVITED: 'secondary', + NONE: 'secondary', +} + +const roleColors: Record = { + JURY_MEMBER: 'default', + MENTOR: 'secondary', + OBSERVER: 'outline', + PROGRAM_ADMIN: 'default', + SUPER_ADMIN: 'default', + APPLICANT: 'secondary', + AWARD_MASTER: 'outline', + AUDIENCE: 'outline', +} export default function MemberDetailPage() { const params = useParams() @@ -78,6 +111,7 @@ export default function MemberDetailPage() { const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const updateUser = trpc.user.update.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation() + const startImpersonation = trpc.user.startImpersonation.useMutation() // Mentor assignments (only fetched for mentors) const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery( @@ -129,7 +163,6 @@ export default function MemberDetailPage() { utils.user.get.invalidate({ id: userId }) utils.user.list.invalidate() toast.success('Member updated successfully') - router.push('/admin/members') } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to update member') } @@ -146,20 +179,37 @@ export default function MemberDetailPage() { } } + const handleImpersonate = async () => { + try { + const result = await startImpersonation.mutateAsync({ targetUserId: userId }) + const ok = await directSessionUpdate({ impersonate: userId }) + if (!ok) { + toast.error('Failed to update session for impersonation') + return + } + window.location.href = getRoleHomePath(result.targetRole) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to start impersonation') + } + } + if (isLoading) { return (
- - +
+ +
- - - - - - +
+
+
+
+ +
+ +
) } @@ -172,11 +222,6 @@ export default function MemberDetailPage() { Error Loading Member {error?.message || 'The member you\'re looking for does not exist.'} - {process.env.NODE_ENV === 'development' && ( -
- User ID: {userId} -
- )}
- + {/* Back nav */} + -
+ {/* Header Hero */} +

{user.name || 'Unnamed Member'}

-
-

{user.email}

- +

{user.email}

+
+ {user.status === 'NONE' ? 'Not Invited' : user.status} + {displayRoles.map((r) => ( + + {r.replace(/_/g, ' ')} + + ))}
- {(user.status === 'NONE' || user.status === 'INVITED') && ( +
+ {(user.status === 'NONE' || user.status === 'INVITED') && ( + + )} - )} +
@@ -252,351 +317,369 @@ export default function MemberDetailPage() { - {/* Profile Details (read-only) */} - {(user.nationality || user.country || user.institution || user.bio) && ( - - - - - Profile Details - - Information provided during onboarding - - -
- {user.nationality && ( -
- {getCountryFlag(user.nationality)} -
-

Nationality

-

{getCountryName(user.nationality)}

-
-
- )} - {user.country && ( -
- {getCountryFlag(user.country)} -
-

Country of Residence

-

{getCountryName(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 */} - - - - - Basic Information - - - -
- - setEmail(e.target.value)} - /> -
-
- - setName(e.target.value)} - placeholder="Enter name" - /> -
-
- - -
-
- - -
-
-
- - {/* Expertise & Capacity — only for jury/mentor/observer/admin roles */} - {!['APPLICANT', 'AUDIENCE'].includes(user.role) && ( - - - - - Expertise & Capacity - - - -
- - -
-
- - setMaxAssignments(e.target.value)} - placeholder="Unlimited" - /> -
- {user._count && ( -
-

Statistics

-
-
-

Jury Assignments

-

{user._count.assignments}

-
-
-

Mentor Assignments

-

{user._count.mentorAssignments}

-
-
-
- )} -
-
- )} -
- - {/* Mentor Assignments Section */} - {user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && ( - - - Mentored Projects - - Projects this mentor is assigned to - - - - - - - Project - Category - Status - Assigned - - - - {mentorAssignments.assignments.map((assignment) => ( - - - - {assignment.project.title} - - {assignment.project.teamName && ( -

- {assignment.project.teamName} -

+
+ {/* Left column: Profile info + Projects */} +
+ {/* Profile Details (read-only) */} + {(user.nationality || user.country || user.institution || user.bio) && ( + + + +
+ +
+ Profile Details +
+ Information provided during onboarding +
+ +
+ {user.nationality && ( +
+ {getCountryFlag(user.nationality)} +
+

Nationality

+

{getCountryName(user.nationality)}

+
+
)} - - - {assignment.project.competitionCategory ? ( - - {assignment.project.competitionCategory.replace('_', ' ')} + {user.country && ( +
+ {getCountryFlag(user.country)} +
+

Country of Residence

+

{getCountryName(user.country)}

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

Institution / Organization

+

{user.institution}

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

Bio

+

{user.bio}

+
+
+
+ )} +
+
+
+ )} + + {/* 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 */} + {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'}) + - ) : ( - '-' - )} - - - - {assignment.project.status ?? 'SUBMITTED'} - - - - {new Date(assignment.assignedAt).toLocaleDateString()} - - - ))} - -
-
-
- )} + ))} +
+ + + )} - {/* Activity Log */} - + {/* Mentor Assignments */} + {user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && ( + + + +
+ +
+ Mentored Projects +
+ + {mentorAssignments.assignments.length} project{mentorAssignments.assignments.length !== 1 ? 's' : ''} assigned + +
+ + + + + Project + Category + Status + Assigned + + + + {mentorAssignments.assignments.map((assignment) => ( + + + + {assignment.project.title} + + {assignment.project.teamName && ( +

{assignment.project.teamName}

+ )} +
+ + {assignment.project.competitionCategory ? ( + {assignment.project.competitionCategory.replace('_', ' ')} + ) : '-'} + + + {assignment.project.status ?? 'SUBMITTED'} + + + {new Date(assignment.assignedAt).toLocaleDateString()} + +
+ ))} +
+
+
+
+ )} - {/* Status Alert */} - {user.status === 'NONE' && ( - - - Not Yet Invited - - This member was added to the platform via project import but hasn't been - invited yet. Send them an invitation using the button above. - - - )} - {user.status === 'INVITED' && ( - - - Invitation Pending - - This member hasn't accepted their invitation yet. You can resend the - invitation email using the button above. - - - )} + {/* Activity Log */} + +
- {/* Save Button */} -
- - -
+ {/* Right sidebar: Edit form + Quick info */} +
+ {/* Quick Info Card */} + + + +
+ +
+ Quick Info +
+
+ +
+ Created + {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'} +
+
+ Last Login + + {user.lastLoginAt ? ( + + {formatRelativeTime(user.lastLoginAt)} + + ) : 'Never'} + +
+ {user._count && !['APPLICANT', 'AUDIENCE'].includes(user.role) && ( + <> +
+ Jury Assignments + {user._count.assignments} +
+
+ Mentor Assignments + {user._count.mentorAssignments} +
+ + )} +
+
+ + {/* Status Alerts */} + {user.status === 'NONE' && ( + + + Not Yet Invited + + This member was added via import but hasn't been invited yet. + + + )} + {user.status === 'INVITED' && ( + + + Invitation Pending + + This member hasn't accepted their invitation yet. + + + )} + + {/* Basic Info Edit */} + + + +
+ +
+ Edit Details +
+
+ +
+ + setEmail(e.target.value)} + /> +
+
+ + setName(e.target.value)} + placeholder="Enter name" + /> +
+
+ + +
+
+ + +
+ + {/* Expertise & Capacity for non-applicant */} + {!['APPLICANT', 'AUDIENCE'].includes(user.role) && ( + <> +
+ + +
+
+ + setMaxAssignments(e.target.value)} + placeholder="Unlimited" + /> +
+ + )} + + +
+
+
+ {/* Evaluations Tab */} @@ -611,7 +694,6 @@ export default function MemberDetailPage() { ) : ( (() => { - // Group evaluations by round const byRound = new Map() for (const ev of jurorEvaluations) { const key = ev.roundName @@ -712,7 +794,6 @@ export default function MemberDetailPage() { )} - {/* Super Admin Confirmation Dialog */} @@ -725,11 +806,7 @@ export default function MemberDetailPage() { - { - setPendingSuperAdminRole(false) - }} - > + setPendingSuperAdminRole(false)}> Cancel void +}) { + const isActive = round.roundStatus === 'ROUND_ACTIVE' + return ( + + ) +} + +function PipelineView({ + rounds, + selectedRoundId, + onSelectRound, +}: { + rounds: RoundOverviewItem[] + selectedRoundId: string + onSelectRound: (id: string) => void +}) { + // Split main pipeline from award tracks + const mainRounds = rounds.filter((r) => !r.specialAwardId) + const awardGroups = new Map() + for (const r of rounds) { + if (!r.specialAwardId) continue + if (!awardGroups.has(r.specialAwardId)) { + awardGroups.set(r.specialAwardId, { name: r.specialAwardName ?? 'Special Award', rounds: [] }) + } + awardGroups.get(r.specialAwardId)!.rounds.push(r) + } + + return ( +
+ {/* Main Competition Pipeline */} + {mainRounds.length > 0 && ( +
+ {mainRounds.map((round, idx) => ( +
+ onSelectRound(round.roundId)} + /> + {idx < mainRounds.length - 1 && ( +
+ )} +
+ ))} +
+ )} + + {/* Award Tracks */} + {awardGroups.size > 0 && ( +
+ {Array.from(awardGroups.entries()).map(([awardId, group]) => ( +
+
+
+ +
+

{group.name}

+ + Award Track + +
+
+ {group.rounds.map((round, idx) => ( +
+ onSelectRound(round.roundId)} + /> + {idx < group.rounds.length - 1 && ( +
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} + export function ObserverDashboardContent({ userName }: { userName?: string }) { const { programs, @@ -197,71 +350,11 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { ))}
) : roundOverview && roundOverview.rounds.length > 0 ? ( -
- {roundOverview.rounds.map((round, idx) => { - const isSelected = selectedRoundId === round.roundId - const isActive = round.roundStatus === 'ROUND_ACTIVE' - return ( -
- - {idx < roundOverview.rounds.length - 1 && ( -
- )} -
- ) - })} -
+ ) : (

No round data available for this competition.

)} diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 1e3a822..0461144 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -867,6 +867,8 @@ export const analyticsRouter = router({ roundType: true, status: true, sortOrder: true, + specialAwardId: true, + specialAward: { select: { name: true } }, }, }) @@ -943,6 +945,8 @@ export const analyticsRouter = router({ roundType: round.roundType, roundStatus: round.status, sortOrder: round.sortOrder, + specialAwardId: round.specialAwardId, + specialAwardName: round.specialAward?.name ?? null, totalProjects, stateBreakdown, totalAssignments,