feat: Mentees & Activity tab on /admin/mentors
Adds a project-centric ops view for mentor management: - New mentor.getMenteeActivity tRPC procedure aggregates every project with wantsMentorship=true and derives a status (unassigned / assigned / active / stalled) from the latest message + file activity. - /admin/mentors becomes a tabbed page: existing Mentor list + new Mentees & Activity table with status pills, search, and a per-row Assign/Open CTA linking to /admin/projects/[id]/mentor. - Includes 2 unit tests covering classification + program scoping. Also: ignore .remember/ (plugin scratch dir).
This commit is contained in:
@@ -13,6 +13,7 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowUpDown, Search, Users } from 'lucide-react'
|
||||
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
||||
import { formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||
|
||||
@@ -37,6 +39,16 @@ function formatRelativePast(date: Date | string | null): string {
|
||||
return `${Math.max(0, minutes)}m ago`
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
'unassigned' | 'assigned' | 'active' | 'stalled',
|
||||
{ label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||
> = {
|
||||
unassigned: { label: 'Unassigned', variant: 'outline' },
|
||||
assigned: { label: 'Assigned', variant: 'secondary' },
|
||||
active: { label: 'Active', variant: 'default' },
|
||||
stalled: { label: 'Stalled', variant: 'destructive' },
|
||||
}
|
||||
|
||||
type Mentor = {
|
||||
id: string
|
||||
name: string | null
|
||||
@@ -50,7 +62,7 @@ type Mentor = {
|
||||
lastActivityAt: Date | string | null
|
||||
}
|
||||
|
||||
export default function MentorsListPage() {
|
||||
function MentorListPanel() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
@@ -128,21 +140,6 @@ export default function MentorsListPage() {
|
||||
|
||||
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">
|
||||
@@ -279,3 +276,252 @@ export default function MentorsListPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||
|
||||
function MenteeActivityPanel() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
|
||||
const { data, isLoading } = trpc.mentor.getMenteeActivity.useQuery({})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data) return []
|
||||
const q = search.trim().toLowerCase()
|
||||
return data.rows.filter((r) => {
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||
if (!q) return true
|
||||
const hay = [
|
||||
r.project.title,
|
||||
r.project.country ?? '',
|
||||
r.teamLead?.name ?? '',
|
||||
r.teamLead?.email ?? '',
|
||||
r.mentor?.name ?? '',
|
||||
r.mentor?.email ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
})
|
||||
}, [data, search, statusFilter])
|
||||
|
||||
const totals = data?.totals ?? { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||
|
||||
const StatusPill = ({ value, label, count }: { value: StatusFilter; label: string; count: number }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
statusFilter === value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label} <span className="tabular-nums opacity-80">({count})</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
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">
|
||||
<CardTitle className="text-base">Mentee teams</CardTitle>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusPill
|
||||
value="all"
|
||||
label="All"
|
||||
count={
|
||||
totals.unassigned + totals.assigned + totals.active + totals.stalled
|
||||
}
|
||||
/>
|
||||
<StatusPill value="unassigned" label="Unassigned" count={totals.unassigned} />
|
||||
<StatusPill value="assigned" label="Assigned" count={totals.assigned} />
|
||||
<StatusPill value="active" label="Active" count={totals.active} />
|
||||
<StatusPill value="stalled" label="Stalled" count={totals.stalled} />
|
||||
</div>
|
||||
</div>
|
||||
<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 project, team lead, or mentor…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{search || statusFilter !== 'all'
|
||||
? 'No matching teams.'
|
||||
: 'No teams have requested mentorship yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Mentor</TableHead>
|
||||
<TableHead className="text-right">Messages</TableHead>
|
||||
<TableHead className="text-right">Files</TableHead>
|
||||
<TableHead>Last activity</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((r) => {
|
||||
const badge = STATUS_BADGE[r.status]
|
||||
return (
|
||||
<TableRow key={r.project.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.project.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{r.teamLead?.name ?? r.teamLead?.email ?? '—'}
|
||||
{r.project.oceanIssue && (
|
||||
<>
|
||||
{' · '}
|
||||
{formatEnumLabel(r.project.oceanIssue)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant} className="text-xs">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{r.mentor ? (
|
||||
<div className="text-sm">
|
||||
<div>{r.mentor.name ?? r.mentor.email}</div>
|
||||
<div className="text-muted-foreground text-xs tabular-nums">
|
||||
{r.mentor.currentLoad}
|
||||
{r.mentor.maxAssignments != null
|
||||
? `/${r.mentor.maxAssignments}`
|
||||
: ''}
|
||||
{' load'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{r.messageCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{r.fileCount}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatRelativePast(r.lastActivityAt as unknown as Date | null)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/admin/projects/${r.project.id}/mentor`}>
|
||||
{r.mentor ? 'Open' : 'Assign'}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentorsListPage() {
|
||||
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">
|
||||
Manage the mentor pool and track mentee teams across the program.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/members">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Members
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="mentors" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="mentors">
|
||||
<GraduationCap className="mr-2 h-4 w-4" /> Mentors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mentees">
|
||||
<Users className="mr-2 h-4 w-4" /> Mentees & Activity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mentors">
|
||||
<MentorListPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="mentees">
|
||||
<MenteeActivityPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user