feat(admin): real /admin/mentors list page (§B)
Replaces the redirect-to-/admin/members stub with a sortable, searchable list of all MENTOR-role users powered by mentor.getMentorPool. Columns: name, expertise tags, country, active count, completed count, capacity remaining, last activity. Header summary cards show pool size, total active assignments, and average load. Row links continue to /admin/members/[id]; /admin/mentors/[id] remains a redirect (mentor-detail view deferred to a future PR). Plan: docs/superpowers/plans/2026-04-28-pr5-mentor-round-overview.md
This commit is contained in:
@@ -1,5 +1,281 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
'use client'
|
||||
|
||||
export default function MentorsPage() {
|
||||
redirect('/admin/members')
|
||||
import { useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowUpDown, Search, Users } from 'lucide-react'
|
||||
|
||||
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||
|
||||
function formatRelativePast(date: Date | string | null): string {
|
||||
if (!date) return '—'
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const ms = Date.now() - d.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`
|
||||
}
|
||||
|
||||
type Mentor = {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
country: string | null
|
||||
expertiseTags: string[]
|
||||
currentAssignments: number
|
||||
completedAssignments: number
|
||||
maxAssignments: number | null
|
||||
capacityRemaining: number | null
|
||||
lastActivityAt: Date | string | null
|
||||
}
|
||||
|
||||
export default function MentorsListPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||
|
||||
const filtered = useMemo<Mentor[]>(() => {
|
||||
if (!data) return []
|
||||
const q = search.trim().toLowerCase()
|
||||
let rows: Mentor[] = data.mentors
|
||||
if (q) {
|
||||
rows = rows.filter((m) =>
|
||||
[m.name ?? '', m.email, m.country ?? '', ...m.expertiseTags]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
}
|
||||
rows = [...rows].sort((a, b) => {
|
||||
let av: string | number = 0
|
||||
let bv: string | number = 0
|
||||
switch (sortKey) {
|
||||
case 'name':
|
||||
av = (a.name ?? '').toLowerCase()
|
||||
bv = (b.name ?? '').toLowerCase()
|
||||
break
|
||||
case 'load':
|
||||
av = a.currentAssignments
|
||||
bv = b.currentAssignments
|
||||
break
|
||||
case 'capacity':
|
||||
av = a.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||
bv = b.capacityRemaining ?? Number.MAX_SAFE_INTEGER
|
||||
break
|
||||
case 'lastActivity':
|
||||
av = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0
|
||||
bv = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0
|
||||
break
|
||||
}
|
||||
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
return rows
|
||||
}, [data, search, sortKey, sortDir])
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
else {
|
||||
setSortKey(key)
|
||||
setSortDir(key === 'name' ? 'asc' : 'desc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortHeader = ({
|
||||
k,
|
||||
children,
|
||||
align = 'left',
|
||||
}: {
|
||||
k: SortKey
|
||||
children: React.ReactNode
|
||||
align?: 'left' | 'right'
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(k)}
|
||||
className={`flex items-center gap-1 text-sm font-medium ${align === 'right' ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{children}
|
||||
<ArrowUpDown
|
||||
className={`h-3 w-3 ${sortKey === k ? 'text-foreground' : 'text-muted-foreground/50'}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Mentors</h1>
|
||||
<p className="text-muted-foreground">
|
||||
All users with the MENTOR role and their current workload.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/members">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Members
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, email, country, or expertise tag…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{search ? 'No matching mentors.' : 'No mentors yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<SortHeader k="name">Mentor</SortHeader>
|
||||
</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
|
||||
</SortHeader>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortHeader k="lastActivity">Last activity</SortHeader>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((m) => (
|
||||
<TableRow key={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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{m.expertiseTags.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{m.expertiseTags.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{m.expertiseTags.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user