From ddae34c8f5dc458653fe425c9bfc822c2ce561ab Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 14:56:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(mentor):=20rewrite=20project=20mentor-assi?= =?UTF-8?q?gnment=20page=20(=C2=A7C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/projects/[id]/mentor/page.tsx | 924 +++++++++++------- 1 file changed, 546 insertions(+), 378 deletions(-) diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index 0bd2402..fa0aa66 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -1,378 +1,546 @@ -'use client' - -import { Suspense, use, useState } from 'react' -import { useRouter } from 'next/navigation' -import { trpc } from '@/lib/trpc/client' -import { toast } from 'sonner' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Button } from '@/components/ui/button' -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 { - ArrowLeft, - Bot, - Loader2, - Users, - Check, - RefreshCw, -} from 'lucide-react' -import { getInitials } from '@/lib/utils' - -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(null) - - const utils = trpc.useUtils() - - // Fetch project - 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 } - ) - - // Assign mentor mutation - const assignMutation = trpc.mentor.assign.useMutation({ - onSuccess: () => { - toast.success('Mentor assigned!') - utils.project.get.invalidate({ id: projectId }) - utils.mentor.getSuggestions.invalidate({ projectId }) - }, - onError: (error) => { - toast.error(error.message) - }, - }) - - // 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.getSuggestions.invalidate({ projectId }) - }, - onError: (error) => { - toast.error(error.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, - }) - } - - if (projectLoading) { - return - } - - if (!project) { - return ( - - -

Project not found

-
-
- ) - } - - const hasMentor = !!project.mentorAssignment - - return ( -
- {/* Header */} -
- -
- -
-

Mentor Assignment

-

{project.title}

-
- - {/* Current Assignment */} - {hasMentor && ( - - - Current Mentor - - -
-
- - - {getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)} - - -
-

{project.mentorAssignment!.mentor.name || 'Unnamed'}

-

{project.mentorAssignment!.mentor.email}

- {project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && ( -
- {project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => ( - {tag} - ))} -
- )} -
-
-
- - {project.mentorAssignment!.method.replace(/_/g, ' ')} - -
- -
-
-
-
-
- )} - - {/* AI Suggestions */} - {!hasMentor && ( - <> - - -
-
- - - AI-Suggested Mentors - - - AI Recommended - - - - Mentors matched based on expertise and project needs - -
-
- - -
-
-
- - {suggestionsLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ) : suggestions?.suggestions.length === 0 ? ( -

- No mentor suggestions available. Try adding more users with expertise tags. -

- ) : ( -
- {suggestions?.suggestions.map((suggestion, index) => ( -
-
-
-
- - - {suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'} - - - {index === 0 && ( -
- 1 -
- )} -
-
-
-

{suggestion.mentor?.name || 'Unnamed'}

- - {suggestion.mentor?.assignmentCount || 0} projects - -
-

{suggestion.mentor?.email}

- - {/* Expertise tags */} - {suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && ( -
- {suggestion.mentor.expertiseTags.map((tag) => ( - - {tag} - - ))} -
- )} - - {/* Match scores */} -
-
- Confidence: - - {(suggestion.confidenceScore * 100).toFixed(0)}% -
-
- Expertise Match: - - {(suggestion.expertiseMatchScore * 100).toFixed(0)}% -
-
- - {/* AI Reasoning */} - {suggestion.reasoning && ( -

- "{suggestion.reasoning}" -

- )} -
-
- - -
-
- ))} -
- )} -
-
- - - )} -
- ) -} - -function MentorAssignmentSkeleton() { - return ( -
- -
- - -
- - - - - - -
- {[1, 2, 3].map((i) => ( - - ))} -
-
-
-
- ) -} - -export default function MentorAssignmentPage({ params }: PageProps) { - const { id } = use(params) - - return ( - }> - - - ) -} +'use client' + +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 { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +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, + Check, + Loader2, + Search, + Sparkles, + Users, +} from 'lucide-react' +import { getInitials } from '@/lib/utils' + +interface PageProps { + params: Promise<{ id: string }> +} + +function MentorAssignmentContent({ projectId }: { projectId: string }) { + const router = useRouter() + const utils = trpc.useUtils() + const [search, setSearch] = useState('') + const [pendingMentorId, setPendingMentorId] = useState(null) + + const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId }) + + 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 }, + ) + + const assignMutation = trpc.mentor.assign.useMutation({ + onSuccess: () => { + toast.success('Mentor assigned') + utils.project.get.invalidate({ id: projectId }) + utils.mentor.getCandidates.invalidate({ projectId }) + utils.mentor.getSuggestions.invalidate({ projectId }) + setPendingMentorId(null) + }, + onError: (err) => { + toast.error(err.message) + setPendingMentorId(null) + }, + }) + + 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: (err) => toast.error(err.message), + }) + + 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) + }) + }, [candidatesData, search]) + + if (projectLoading) return + if (!project) { + return ( + + +

Project not found

+
+
+ ) + } + + 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 ( +
+
+ +
+ +
+

Mentor Assignment

+

{project.title}

+
+ + {/* ─── Project Context ─── */} + + + Project Context + What this project needs from a mentor + + +
+
+
Ocean Issue
+
{project.oceanIssue ?? '—'}
+
+
+
Category
+
{project.competitionCategory ?? '—'}
+
+
+
Country
+
{project.country ?? '—'}
+
+
+
Team Size
+
{teamSize}
+
+
+
+ Mentoring Requested +
+
{project.wantsMentorship ? 'Yes' : 'No'}
+
+
+ {project.tags && project.tags.length > 0 && ( +
+
+ Project Tags +
+
+ {project.tags.map((tag: string) => ( + + {tag} + + ))} +
+
+ )} +
+
+ + {/* ─── Currently Assigned ─── */} + + + Currently Assigned + + + {hasMentor ? ( +
+
+ + + {getInitials( + project.mentorAssignment!.mentor.name || + project.mentorAssignment!.mentor.email, + )} + + +
+ + {project.mentorAssignment!.mentor.name || 'Unnamed'} + +

+ {project.mentorAssignment!.mentor.email} +

+ {project.mentorAssignment!.mentor.expertiseTags && + project.mentorAssignment!.mentor.expertiseTags.length > 0 && ( +
+ {project.mentorAssignment!.mentor.expertiseTags + .slice(0, 5) + .map((tag: string) => ( + + {tag} + + ))} +
+ )} +
+
+
+ + {project.mentorAssignment!.method.replace(/_/g, ' ')} + + +
+
+ ) : ( +

+ No mentor assigned yet — pick one below. +

+ )} +
+
+ + {/* ─── Pick a Mentor ─── */} + {!hasMentor && ( + + + Pick a Mentor + + Browse all eligible mentors or use AI to surface the best fits. + + + + + + + Manual Picker + + + AI Suggestions + + + + +
+ + setSearch(e.target.value)} + placeholder="Search by name, email, country, or expertise tag…" + className="pl-9" + /> +
+ {candidatesLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : filteredCandidates.length === 0 ? ( +
+ No matching mentors. Try a different search. +
+ ) : ( +
+ + + + Mentor + Expertise + Country + Load + Overlap + + + + + {filteredCandidates.map((c) => ( + + +
{c.name ?? 'Unnamed'}
+
{c.email}
+
+ +
+ {c.expertiseTags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + {c.expertiseTags.length > 4 && ( + + +{c.expertiseTags.length - 4} + + )} +
+
+ {c.country ?? '—'} + + {c.currentAssignments} + {c.maxAssignments != null ? `/${c.maxAssignments}` : ''} + + + = 0.5 + ? 'default' + : c.overlapScore > 0 + ? 'secondary' + : 'outline' + } + className="text-xs tabular-nums" + > + {Math.round(c.overlapScore * 100)}% + + + + + +
+ ))} +
+
+
+ )} +
+ + + {aiSource === 'fallback' && ( +
+ +
+

AI matching unavailable

+

+ Showing expertise-tag overlap instead. Configure{' '} + OPENAI_API_KEY to enable AI matching. +

+
+
+ )} +
+ +
+ {suggestionsLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : !suggestionsData || suggestionsData.suggestions.length === 0 ? ( +

+ No suggestions available. +

+ ) : ( +
+ {suggestionsData.suggestions.map((s, i) => ( +
+
+
+ + + {s.mentor + ? getInitials(s.mentor.name || s.mentor.email) + : '?'} + + + {i === 0 && ( +
+ 1 +
+ )} +
+
+
+

{s.mentor?.name || 'Unnamed'}

+ + {' '} + {aiSource === 'ai' ? 'AI' : 'Tag overlap'} + +
+

{s.mentor?.email}

+ {s.mentor?.expertiseTags && s.mentor.expertiseTags.length > 0 && ( +
+ {s.mentor.expertiseTags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ Confidence: + + + {Math.round(s.confidenceScore * 100)}% + +
+
+ + Expertise Match: + + + + {Math.round(s.expertiseMatchScore * 100)}% + +
+
+ {s.reasoning && ( +

+ "{s.reasoning}" +

+ )} +
+
+ +
+ ))} +
+ )} +
+
+
+
+ )} +
+ ) +} + +function MentorAssignmentSkeleton() { + return ( +
+ +
+ + +
+ + + + + + + + +
+ ) +} + +export default function MentorAssignmentPage({ params }: PageProps) { + const { id } = use(params) + return ( + }> + + + ) +}