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