feat(mentor): rewrite project mentor-assignment page (§C)

Replaces single-section AI-only stub with three sections (Project Context,
Currently Assigned, Pick a Mentor). Pick a Mentor is a tab strip:
  - Manual Picker (default): all MENTOR-role users sorted by expertise
    overlap %, with search + load/capacity columns. Assign sends
    method=MANUAL.
  - AI Suggestions: existing pane, with an amber 'AI matching unavailable'
    banner + 'Tag overlap' pills when OPENAI_API_KEY is unset.

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
This commit is contained in:
Matt
2026-04-28 14:56:46 +02:00
parent 4874491b18
commit ddae34c8f5

View File

@@ -1,7 +1,8 @@
'use client'
import { Suspense, use, useState } from 'react'
import { Suspense, use, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
@@ -16,13 +17,25 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
AlertTriangle,
ArrowLeft,
Bot,
Loader2,
Users,
Check,
RefreshCw,
Loader2,
Search,
Sparkles,
Users,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
@@ -30,89 +43,66 @@ interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
const [search, setSearch] = useState('')
const [pendingMentorId, setPendingMentorId] = useState<string | null>(null)
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
const { data: candidatesData, isLoading: candidatesLoading } =
trpc.mentor.getCandidates.useQuery(
{ projectId },
{ enabled: !!project && !project.mentorAssignment },
)
const {
data: suggestionsData,
isLoading: suggestionsLoading,
refetch: refetchSuggestions,
} = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment },
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
toast.success('Mentor assigned')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
setPendingMentorId(null)
},
onError: (error) => {
toast.error(error.message)
onError: (err) => {
toast.error(err.message)
setPendingMentorId(null)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getCandidates.invalidate({ projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
onError: (err) => toast.error(err.message),
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
const filteredCandidates = useMemo(() => {
if (!candidatesData) return []
const q = search.trim().toLowerCase()
if (!q) return candidatesData.candidates
return candidatesData.candidates.filter((c) => {
const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? '']
.join(' ')
.toLowerCase()
return hay.includes(q)
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
}, [candidatesData, search])
if (projectLoading) return <MentorAssignmentSkeleton />
if (!project) {
return (
<Card>
@@ -124,14 +114,38 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
}
const hasMentor = !!project.mentorAssignment
const teamSize = project.teamMembers?.length ?? 0
const aiSource = suggestionsData?.source ?? 'ai'
const handleAssignManual = (mentorId: string) => {
setPendingMentorId(mentorId)
assignMutation.mutate({ projectId, mentorId, method: 'MANUAL' })
}
const handleAssignFromSuggestion = (
mentorId: string,
suggestion: {
confidenceScore: number
expertiseMatchScore: number
reasoning: string
},
) => {
setPendingMentorId(mentorId)
assignMutation.mutate({
projectId,
mentorId,
method: aiSource === 'ai' ? 'AI_SUGGESTED' : 'ALGORITHM',
aiConfidenceScore: suggestion.confidenceScore,
expertiseMatchScore: suggestion.expertiseMatchScore,
aiReasoning: suggestion.reasoning,
})
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
<ArrowLeft className="mr-2 h-4 w-4" /> Back
</Button>
</div>
@@ -140,37 +154,99 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
{/* ─── Project Context ─── */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
<CardTitle className="text-lg">Project Context</CardTitle>
<CardDescription>What this project needs from a mentor</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-3">
<div>
<div className="text-muted-foreground text-xs uppercase tracking-wide">Ocean Issue</div>
<div className="font-medium">{project.oceanIssue ?? '—'}</div>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase tracking-wide">Category</div>
<div className="font-medium">{project.competitionCategory ?? '—'}</div>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase tracking-wide">Country</div>
<div className="font-medium">{project.country ?? '—'}</div>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase tracking-wide">Team Size</div>
<div className="font-medium">{teamSize}</div>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase tracking-wide">
Mentoring Requested
</div>
<div className="font-medium">{project.wantsMentorship ? 'Yes' : 'No'}</div>
</div>
</div>
{project.tags && project.tags.length > 0 && (
<div className="mt-4">
<div className="text-muted-foreground mb-1.5 text-xs uppercase tracking-wide">
Project Tags
</div>
<div className="flex flex-wrap gap-1">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* ─── Currently Assigned ─── */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Currently Assigned</CardTitle>
</CardHeader>
<CardContent>
{hasMentor ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
{getInitials(
project.mentorAssignment!.mentor.name ||
project.mentorAssignment!.mentor.email,
)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
<Link
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
className="font-medium hover:underline"
>
{project.mentorAssignment!.mentor.name || 'Unnamed'}
</Link>
<p className="text-muted-foreground text-sm">
{project.mentorAssignment!.mentor.email}
</p>
{project.mentorAssignment!.mentor.expertiseTags &&
project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{project.mentorAssignment!.mentor.expertiseTags
.slice(0, 5)
.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
<div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-xs">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
@@ -180,39 +256,153 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
'Unassign'
)}
</Button>
</div>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">
No mentor assigned yet pick one below.
</p>
)}
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{/* ─── Pick a Mentor ─── */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
<Badge variant="outline" className="text-xs gap-1 shrink-0 ml-1">
<Bot className="h-3 w-3" />
AI Recommended
</Badge>
</CardTitle>
<CardTitle className="text-lg">Pick a Mentor</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
Browse all eligible mentors or use AI to surface the best fits.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="manual" className="space-y-4">
<TabsList>
<TabsTrigger value="manual">
<Users className="mr-2 h-4 w-4" /> Manual Picker
</TabsTrigger>
<TabsTrigger value="ai">
<Sparkles className="mr-2 h-4 w-4" /> AI Suggestions
</TabsTrigger>
</TabsList>
<TabsContent value="manual" className="space-y-4">
<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>
<div className="flex gap-2">
{candidatesLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : filteredCandidates.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
No matching mentors. Try a different search.
</div>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Mentor</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">Load</TableHead>
<TableHead className="text-right">Overlap</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCandidates.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
<div className="text-muted-foreground text-xs">{c.email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{c.expertiseTags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{c.expertiseTags.length > 4 && (
<Badge variant="outline" className="text-xs">
+{c.expertiseTags.length - 4}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm">{c.country ?? '—'}</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{c.currentAssignments}
{c.maxAssignments != null ? `/${c.maxAssignments}` : ''}
</TableCell>
<TableCell className="text-right">
<Badge
variant={
c.overlapScore >= 0.5
? 'default'
: c.overlapScore > 0
? 'secondary'
: 'outline'
}
className="text-xs tabular-nums"
>
{Math.round(c.overlapScore * 100)}%
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
onClick={() => handleAssignManual(c.id)}
disabled={assignMutation.isPending}
>
{assignMutation.isPending && pendingMentorId === c.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-3.5 w-3.5" /> Assign
</>
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
<TabsContent value="ai" className="space-y-4">
{aiSource === 'fallback' && (
<div className="flex items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<p className="font-medium">AI matching unavailable</p>
<p className="text-muted-foreground">
Showing expertise-tag overlap instead. Configure{' '}
<code>OPENAI_API_KEY</code> to enable AI matching.
</p>
</div>
</div>
)}
<div className="flex items-center justify-end">
<Button
variant="outline"
onClick={() => refetch()}
size="sm"
onClick={() => refetchSuggestions()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
@@ -221,122 +411,106 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
No suggestions available.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div className="space-y-3">
{suggestionsData.suggestions.map((s, i) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
key={s.mentorId}
className="flex items-start justify-between rounded-md border p-4"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="flex flex-1 gap-3">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
{s.mentor
? getInitials(s.mentor.name || s.mentor.email)
: '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
{i === 0 && (
<div className="bg-primary text-primary-foreground absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
<p className="font-medium">{s.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="gap-1 text-xs">
<Bot className="h-3 w-3" />{' '}
{aiSource === 'ai' ? 'AI' : 'Tag overlap'}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<p className="text-muted-foreground text-sm">{s.mentor?.email}</p>
{s.mentor?.expertiseTags && s.mentor.expertiseTags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{s.mentor.expertiseTags.slice(0, 5).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<div className="mt-3 space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
<Progress value={s.confidenceScore * 100} className="h-1.5 flex-1" />
<span className="w-10 text-right tabular-nums">
{Math.round(s.confidenceScore * 100)}%
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground w-28">
Expertise Match:
</span>
<Progress
value={s.expertiseMatchScore * 100}
className="h-1.5 flex-1"
/>
<span className="w-10 text-right tabular-nums">
{Math.round(s.expertiseMatchScore * 100)}%
</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
{s.reasoning && (
<p className="text-muted-foreground mt-2 text-xs italic">
&quot;{s.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
size="sm"
onClick={() => handleAssignFromSuggestion(s.mentorId, s)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
{assignMutation.isPending && pendingMentorId === s.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
<Check className="mr-1 h-3.5 w-3.5" /> Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</>
)}
</div>
)
@@ -353,14 +527,9 @@ function MentorAssignmentSkeleton() {
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
@@ -369,7 +538,6 @@ function MentorAssignmentSkeleton() {
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />