feat: mentor detail side sheet + Teams column

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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 19:52:17 +02:00
parent 030db533e1
commit 62ab27a05a
3 changed files with 387 additions and 103 deletions

View File

@@ -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<SortKey>('load')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [detailMentorId, setDetailMentorId] = useState<string | null>(null)
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
@@ -140,45 +143,6 @@ function MentorListPanel() {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Pool size
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{data?.poolSize ?? '—'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Active assignments
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">
{data?.totalCurrentAssignments ?? '—'}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Average load
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">
{data && data.poolSize > 0
? (data.totalCurrentAssignments / data.poolSize).toFixed(1)
: '—'}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="space-y-4">
<CardTitle className="text-base">Mentor list</CardTitle>
@@ -213,12 +177,6 @@ function MentorListPanel() {
</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">
<SortHeader k="load" align="right">
Active
</SortHeader>
</TableHead>
<TableHead className="text-right">Completed</TableHead>
<TableHead className="text-right">
<SortHeader k="capacity" align="right">
Capacity
@@ -227,16 +185,21 @@ function MentorListPanel() {
<TableHead>
<SortHeader k="lastActivity">Last activity</SortHeader>
</TableHead>
<TableHead>
<SortHeader k="load">Teams</SortHeader>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((m) => (
<TableRow key={m.id}>
<TableRow
key={m.id}
className="cursor-pointer"
onClick={() => setDetailMentorId(m.id)}
>
<TableCell>
<Link href={`/admin/members/${m.id}`} className="hover:underline">
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{m.email}</div>
</Link>
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{m.email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
@@ -253,18 +216,35 @@ function MentorListPanel() {
</div>
</TableCell>
<TableCell className="text-sm">{m.country ?? '—'}</TableCell>
<TableCell className="text-right tabular-nums">
{m.currentAssignments}
</TableCell>
<TableCell className="text-right tabular-nums">
{m.completedAssignments}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
</TableCell>
<TableCell className="text-sm">
{formatRelativePast(m.lastActivityAt)}
</TableCell>
<TableCell>
{m.activeTeams.length === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : (
<div className="flex flex-wrap gap-1">
{m.activeTeams.slice(0, 2).map((t) => (
<Badge
key={t.id}
variant="outline"
className="max-w-[12rem] truncate text-xs"
title={t.title}
>
{t.title}
</Badge>
))}
{m.activeTeams.length > 2 && (
<Badge variant="outline" className="text-xs">
+{m.activeTeams.length - 2}
</Badge>
)}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -273,6 +253,14 @@ function MentorListPanel() {
)}
</CardContent>
</Card>
<MentorDetailSheet
mentorId={detailMentorId}
open={!!detailMentorId}
onOpenChange={(next) => {
if (!next) setDetailMentorId(null)
}}
/>
</div>
)
}
@@ -323,51 +311,6 @@ function MenteeActivityPanel() {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Unassigned
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{totals.unassigned}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Assigned
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{totals.assigned}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Active
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{totals.active}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Stalled
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums text-destructive">
{totals.stalled}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-4">

View File

@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>
{isLoading || !data ? (
<Skeleton className="h-6 w-48" />
) : (
data.mentor.name ?? 'Unnamed mentor'
)}
</SheetTitle>
<SheetDescription>
{isLoading || !data ? (
<Skeleton className="h-4 w-64" />
) : (
<span className="flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" /> {data.mentor.email}
</span>
{data.mentor.country && (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" /> {data.mentor.country}
</span>
)}
</span>
)}
</SheetDescription>
</SheetHeader>
{isLoading || !data ? (
<div className="mt-6 space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : (
<div className="mt-6 space-y-6">
{/* Profile summary */}
<section className="space-y-3">
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
Profile
</h3>
<div className="space-y-2 rounded-md border p-4">
<div className="flex items-start gap-2 text-sm">
<GraduationCap className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="text-muted-foreground text-xs">Expertise</div>
{data.mentor.expertiseTags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{data.mentor.expertiseTags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{t}
</Badge>
))}
</div>
) : (
<div className="text-muted-foreground text-sm italic">
None declared
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Joined</span>
<span>{formatDateOnly(data.mentor.createdAt)}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground text-xs">Max assignments</span>
<span className="tabular-nums">
{data.mentor.maxAssignments ?? '∞'}
</span>
</div>
</div>
</section>
{/* Assignments */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
Teams ({data.assignments.length})
</h3>
</div>
{data.assignments.length === 0 ? (
<div className="text-muted-foreground rounded-md border py-8 text-center text-sm">
This mentor has no assignments yet.
</div>
) : (
<div className="space-y-3">
{data.assignments.map((a) => {
const isCompleted = a.completionStatus === 'completed'
const isDropped = !!a.droppedAt
return (
<div
key={a.id}
className={`rounded-md border p-4 ${isDropped ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Link
href={`/admin/projects/${a.project.id}`}
className="text-sm font-medium hover:underline"
>
{a.project.title}
</Link>
<div className="text-muted-foreground mt-0.5 flex flex-wrap items-center gap-2 text-xs">
{a.project.competitionCategory && (
<span>
{formatEnumLabel(a.project.competitionCategory)}
</span>
)}
{a.project.country && (
<>
<span>·</span>
<span>{a.project.country}</span>
</>
)}
<span>·</span>
<span>Assigned {formatDateOnly(a.assignedAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{isCompleted ? (
<Badge variant="default" className="gap-1">
<CheckCircle2 className="h-3 w-3" /> Completed
</Badge>
) : isDropped ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" /> Dropped
</Badge>
) : (
<Badge variant="secondary">Active</Badge>
)}
</div>
</div>
{isDropped && a.droppedReason && (
<div className="text-muted-foreground bg-muted/50 mt-3 rounded-md p-2 text-xs">
<strong>Drop reason</strong>
{a.droppedBy ? ` (by ${a.droppedBy})` : ''}: {a.droppedReason}
</div>
)}
<Separator className="my-3" />
{/* Activity counts */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-start gap-2 rounded-md border p-2">
<MessageSquare className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.messageCount}
</div>
<div className="text-muted-foreground">
{a.lastMessageAt
? formatRelativePast(a.lastMessageAt)
: 'no messages'}
</div>
</div>
</div>
<div className="flex items-start gap-2 rounded-md border p-2">
<FileText className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.fileCount}
</div>
<div className="text-muted-foreground">
{a.lastFileAt
? formatRelativePast(a.lastFileAt)
: 'no files'}
</div>
</div>
</div>
<div className="flex items-start gap-2 rounded-md border p-2">
<Target className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
<div>
<div className="font-semibold tabular-nums">
{a.milestoneCompletionCount}
</div>
<div className="text-muted-foreground">
{a.lastMilestoneAt
? formatRelativePast(a.lastMilestoneAt)
: 'no milestones'}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
</div>
)}
</SheetContent>
</Sheet>
)
}

View File

@@ -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,
})),
}
}),
})