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:
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
import { ArrowUpDown, GraduationCap, Search, Users } from 'lucide-react'
|
||||||
import { formatEnumLabel } from '@/lib/utils'
|
import { formatEnumLabel } from '@/lib/utils'
|
||||||
|
import { MentorDetailSheet } from '@/components/admin/mentor/mentor-detail-sheet'
|
||||||
|
|
||||||
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
type SortKey = 'name' | 'load' | 'capacity' | 'lastActivity'
|
||||||
|
|
||||||
@@ -60,12 +61,14 @@ type Mentor = {
|
|||||||
maxAssignments: number | null
|
maxAssignments: number | null
|
||||||
capacityRemaining: number | null
|
capacityRemaining: number | null
|
||||||
lastActivityAt: Date | string | null
|
lastActivityAt: Date | string | null
|
||||||
|
activeTeams: { id: string; title: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function MentorListPanel() {
|
function MentorListPanel() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('load')
|
const [sortKey, setSortKey] = useState<SortKey>('load')
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
const [detailMentorId, setDetailMentorId] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
|
const { data, isLoading } = trpc.mentor.getMentorPool.useQuery({})
|
||||||
|
|
||||||
@@ -140,45 +143,6 @@ function MentorListPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader className="space-y-4">
|
<CardHeader className="space-y-4">
|
||||||
<CardTitle className="text-base">Mentor list</CardTitle>
|
<CardTitle className="text-base">Mentor list</CardTitle>
|
||||||
@@ -213,12 +177,6 @@ function MentorListPanel() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Expertise</TableHead>
|
<TableHead>Expertise</TableHead>
|
||||||
<TableHead>Country</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">
|
<TableHead className="text-right">
|
||||||
<SortHeader k="capacity" align="right">
|
<SortHeader k="capacity" align="right">
|
||||||
Capacity
|
Capacity
|
||||||
@@ -227,16 +185,21 @@ function MentorListPanel() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<SortHeader k="lastActivity">Last activity</SortHeader>
|
<SortHeader k="lastActivity">Last activity</SortHeader>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<SortHeader k="load">Teams</SortHeader>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((m) => (
|
{filtered.map((m) => (
|
||||||
<TableRow key={m.id}>
|
<TableRow
|
||||||
|
key={m.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailMentorId(m.id)}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/admin/members/${m.id}`} className="hover:underline">
|
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
|
||||||
<div className="font-medium">{m.name ?? 'Unnamed'}</div>
|
<div className="text-muted-foreground text-xs">{m.email}</div>
|
||||||
<div className="text-muted-foreground text-xs">{m.email}</div>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -253,18 +216,35 @@ function MentorListPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{m.country ?? '—'}</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">
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
|
{m.capacityRemaining != null ? m.capacityRemaining : '∞'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{formatRelativePast(m.lastActivityAt)}
|
{formatRelativePast(m.lastActivityAt)}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -273,6 +253,14 @@ function MentorListPanel() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<MentorDetailSheet
|
||||||
|
mentorId={detailMentorId}
|
||||||
|
open={!!detailMentorId}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) setDetailMentorId(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,51 +311,6 @@ function MenteeActivityPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<Card>
|
||||||
<CardHeader className="space-y-4">
|
<CardHeader className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
|||||||
257
src/components/admin/mentor/mentor-detail-sheet.tsx
Normal file
257
src/components/admin/mentor/mentor-detail-sheet.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1016,6 +1016,7 @@ export const mentorRouter = router({
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
completionStatus: true,
|
completionStatus: true,
|
||||||
|
project: { select: { id: true, title: true } },
|
||||||
messages: {
|
messages: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
@@ -1043,9 +1044,14 @@ export const mentorRouter = router({
|
|||||||
let current = 0
|
let current = 0
|
||||||
let completed = 0
|
let completed = 0
|
||||||
const activityDates: Date[] = []
|
const activityDates: Date[] = []
|
||||||
|
const activeTeams: { id: string; title: string }[] = []
|
||||||
for (const a of m.mentorAssignments) {
|
for (const a of m.mentorAssignments) {
|
||||||
if (a.completionStatus === 'completed') completed++
|
if (a.completionStatus === 'completed') {
|
||||||
else current++
|
completed++
|
||||||
|
} else {
|
||||||
|
current++
|
||||||
|
activeTeams.push(a.project)
|
||||||
|
}
|
||||||
if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
|
if (a.messages[0]) activityDates.push(a.messages[0].createdAt)
|
||||||
if (a.files[0]) activityDates.push(a.files[0].createdAt)
|
if (a.files[0]) activityDates.push(a.files[0].createdAt)
|
||||||
if (a.milestoneCompletions[0])
|
if (a.milestoneCompletions[0])
|
||||||
@@ -1069,6 +1075,7 @@ export const mentorRouter = router({
|
|||||||
maxAssignments: m.maxAssignments,
|
maxAssignments: m.maxAssignments,
|
||||||
capacityRemaining,
|
capacityRemaining,
|
||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
|
activeTeams,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2305,4 +2312,81 @@ export const mentorRouter = router({
|
|||||||
})
|
})
|
||||||
return dropped
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user