From 62ab27a05a242815b26c5ec6a5804430c46eb0d2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 19:52:17 +0200 Subject: [PATCH] feat: mentor detail side sheet + Teams column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mentor list now ends with a Teams column showing chips of each mentor's active assignments (truncated at 2 + overflow badge). Clicking any row opens a right-side Sheet with the mentor's profile (expertise, country, joined date, max assignments) and a per-team activity feed — project, status (active / completed / dropped), assignment date, and counts of messages / files / milestones with their last timestamp. Stat cards on both the Mentor and Mentee panels were stale and not particularly informative, so they're gone — the table itself is now the focal element on each panel. getMentorPool gained an activeTeams[] field; new getMentorDetail query backs the side sheet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(admin)/admin/mentors/page.tsx | 145 +++------- .../admin/mentor/mentor-detail-sheet.tsx | 257 ++++++++++++++++++ src/server/routers/mentor.ts | 88 +++++- 3 files changed, 387 insertions(+), 103 deletions(-) create mode 100644 src/components/admin/mentor/mentor-detail-sheet.tsx diff --git a/src/app/(admin)/admin/mentors/page.tsx b/src/app/(admin)/admin/mentors/page.tsx index 7eb88d7..22ad73c 100644 --- a/src/app/(admin)/admin/mentors/page.tsx +++ b/src/app/(admin)/admin/mentors/page.tsx @@ -24,6 +24,7 @@ import { } from '@/components/ui/table' import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react' import { formatEnumLabel } from '@/lib/utils' +import { MentorDetailSheet } from '@/components/admin/mentor/mentor-detail-sheet' type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity' @@ -60,12 +61,14 @@ type Mentor = { maxAssignments: number | null capacityRemaining: number | null lastActivityAt: Date | string | null + activeTeams: { id: string; title: string }[] } function MentorListPanel() { const [search, setSearch] = useState('') const [sortKey, setSortKey] = useState('load') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') + const [detailMentorId, setDetailMentorId] = useState(null) const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({}) @@ -140,45 +143,6 @@ function MentorListPanel() { return (
-
- - - - Pool size - - - -
{data?.poolSize ?? '—'}
-
-
- - - - Active assignments - - - -
- {data?.totalCurrentAssignments ?? '—'} -
-
-
- - - - Average load - - - -
- {data && data.poolSize > 0 - ? (data.totalCurrentAssignments / data.poolSize).toFixed(1) - : '—'} -
-
-
-
- Mentor list @@ -213,12 +177,6 @@ function MentorListPanel() { Expertise Country - - - Active - - - Completed Capacity @@ -227,16 +185,21 @@ function MentorListPanel() { Last activity + + Teams + {filtered.map((m) => ( - + setDetailMentorId(m.id)} + > - -
{m.name ?? 'Unnamed'}
-
{m.email}
- +
{m.name ?? 'Unnamed'}
+
{m.email}
@@ -253,18 +216,35 @@ function MentorListPanel() {
{m.country ?? '—'} - - {m.currentAssignments} - - - {m.completedAssignments} - {m.capacityRemaining != null ? m.capacityRemaining : '∞'} {formatRelativePast(m.lastActivityAt)} + + {m.activeTeams.length === 0 ? ( + + ) : ( +
+ {m.activeTeams.slice(0, 2).map((t) => ( + + {t.title} + + ))} + {m.activeTeams.length > 2 && ( + + +{m.activeTeams.length - 2} + + )} +
+ )} +
))}
@@ -273,6 +253,14 @@ function MentorListPanel() { )}
+ + { + if (!next) setDetailMentorId(null) + }} + />
) } @@ -323,51 +311,6 @@ function MenteeActivityPanel() { return (
-
- - - - Unassigned - - - -
{totals.unassigned}
-
-
- - - - Assigned - - - -
{totals.assigned}
-
-
- - - - Active - - - -
{totals.active}
-
-
- - - - Stalled - - - -
- {totals.stalled} -
-
-
-
-
diff --git a/src/components/admin/mentor/mentor-detail-sheet.tsx b/src/components/admin/mentor/mentor-detail-sheet.tsx new file mode 100644 index 0000000..272b80b --- /dev/null +++ b/src/components/admin/mentor/mentor-detail-sheet.tsx @@ -0,0 +1,257 @@ +'use client' + +import Link from 'next/link' +import { trpc } from '@/lib/trpc/client' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import { + Mail, + MapPin, + GraduationCap, + CheckCircle2, + XCircle, + MessageSquare, + FileText, + Target, + Calendar, +} from 'lucide-react' +import { formatEnumLabel } from '@/lib/utils' + +function formatDateOnly(d: Date | string | null | undefined): string { + if (!d) return '—' + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(d)) +} + +function formatRelativePast(d: Date | string | null): string { + if (!d) return '—' + const dt = typeof d === 'string' ? new Date(d) : d + const ms = Date.now() - dt.getTime() + const days = Math.floor(ms / 86_400_000) + const hours = Math.floor(ms / 3_600_000) + if (days >= 1) return `${days}d ago` + if (hours >= 1) return `${hours}h ago` + const minutes = Math.floor(ms / 60_000) + return `${Math.max(0, minutes)}m ago` +} + +export function MentorDetailSheet({ + mentorId, + open, + onOpenChange, +}: { + mentorId: string | null + open: boolean + onOpenChange: (next: boolean) => void +}) { + const { data, isLoading } = trpc.mentor.getMentorDetail.useQuery( + { mentorId: mentorId ?? '' }, + { enabled: open && !!mentorId }, + ) + + return ( + + + + + {isLoading || !data ? ( + + ) : ( + data.mentor.name ?? 'Unnamed mentor' + )} + + + {isLoading || !data ? ( + + ) : ( + + + {data.mentor.email} + + {data.mentor.country && ( + + {data.mentor.country} + + )} + + )} + + + + {isLoading || !data ? ( +
+ + +
+ ) : ( +
+ {/* Profile summary */} +
+

+ Profile +

+
+
+ +
+
Expertise
+ {data.mentor.expertiseTags.length > 0 ? ( +
+ {data.mentor.expertiseTags.map((t) => ( + + {t} + + ))} +
+ ) : ( +
+ None declared +
+ )} +
+
+
+ + Joined + {formatDateOnly(data.mentor.createdAt)} +
+
+ Max assignments + + {data.mentor.maxAssignments ?? '∞'} + +
+
+
+ + {/* Assignments */} +
+
+

+ Teams ({data.assignments.length}) +

+
+ + {data.assignments.length === 0 ? ( +
+ This mentor has no assignments yet. +
+ ) : ( +
+ {data.assignments.map((a) => { + const isCompleted = a.completionStatus === 'completed' + const isDropped = !!a.droppedAt + return ( +
+
+
+ + {a.project.title} + +
+ {a.project.competitionCategory && ( + + {formatEnumLabel(a.project.competitionCategory)} + + )} + {a.project.country && ( + <> + · + {a.project.country} + + )} + · + Assigned {formatDateOnly(a.assignedAt)} +
+
+
+ {isCompleted ? ( + + Completed + + ) : isDropped ? ( + + Dropped + + ) : ( + Active + )} +
+
+ + {isDropped && a.droppedReason && ( +
+ Drop reason + {a.droppedBy ? ` (by ${a.droppedBy})` : ''}: {a.droppedReason} +
+ )} + + + + {/* Activity counts */} +
+
+ +
+
+ {a.messageCount} +
+
+ {a.lastMessageAt + ? formatRelativePast(a.lastMessageAt) + : 'no messages'} +
+
+
+
+ +
+
+ {a.fileCount} +
+
+ {a.lastFileAt + ? formatRelativePast(a.lastFileAt) + : 'no files'} +
+
+
+
+ +
+
+ {a.milestoneCompletionCount} +
+
+ {a.lastMilestoneAt + ? formatRelativePast(a.lastMilestoneAt) + : 'no milestones'} +
+
+
+
+
+ ) + })} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index a0f757b..0fe8ff4 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1016,6 +1016,7 @@ export const mentorRouter = router({ }, select: { completionStatus: true, + project: { select: { id: true, title: true } }, messages: { orderBy: { createdAt: 'desc' }, take: 1, @@ -1043,9 +1044,14 @@ export const mentorRouter = router({ let current = 0 let completed = 0 const activityDates: Date[] = [] + const activeTeams: { id: string; title: string }[] = [] for (const a of m.mentorAssignments) { - if (a.completionStatus === 'completed') completed++ - else current++ + if (a.completionStatus === 'completed') { + completed++ + } else { + current++ + activeTeams.push(a.project) + } if (a.messages[0]) activityDates.push(a.messages[0].createdAt) if (a.files[0]) activityDates.push(a.files[0].createdAt) if (a.milestoneCompletions[0]) @@ -1069,6 +1075,7 @@ export const mentorRouter = router({ maxAssignments: m.maxAssignments, capacityRemaining, lastActivityAt, + activeTeams, } }) @@ -2305,4 +2312,81 @@ export const mentorRouter = router({ }) return dropped }), + + /** + * Per-mentor activity detail used by the admin mentor side sheet. For each + * (active or dropped) assignment, returns the project, key timestamps, and + * counts of messages, files, and completed milestones so the admin can see + * "what has this mentor been up to" at a glance. + */ + getMentorDetail: adminProcedure + .input(z.object({ mentorId: z.string() })) + .query(async ({ ctx, input }) => { + const mentor = await ctx.prisma.user.findUniqueOrThrow({ + where: { id: input.mentorId }, + select: { + id: true, + name: true, + email: true, + country: true, + expertiseTags: true, + maxAssignments: true, + createdAt: true, + }, + }) + + const assignments = await ctx.prisma.mentorAssignment.findMany({ + where: { mentorId: input.mentorId }, + orderBy: { assignedAt: 'desc' }, + include: { + project: { + select: { + id: true, + title: true, + competitionCategory: true, + country: true, + }, + }, + _count: { + select: { messages: true, files: true, milestoneCompletions: true }, + }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + files: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { createdAt: true }, + }, + milestoneCompletions: { + orderBy: { completedAt: 'desc' }, + take: 1, + select: { completedAt: true }, + }, + }, + }) + + return { + mentor, + assignments: assignments.map((a) => ({ + id: a.id, + assignedAt: a.assignedAt, + method: a.method, + completionStatus: a.completionStatus, + droppedAt: a.droppedAt, + droppedReason: a.droppedReason, + droppedBy: a.droppedBy, + workspaceEnabled: a.workspaceEnabled, + project: a.project, + messageCount: a._count.messages, + fileCount: a._count.files, + milestoneCompletionCount: a._count.milestoneCompletions, + lastMessageAt: a.messages[0]?.createdAt ?? null, + lastFileAt: a.files[0]?.createdAt ?? null, + lastMilestoneAt: a.milestoneCompletions[0]?.completedAt ?? null, + })), + } + }), })