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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user