From 83e950bb676a3e820f0f2e7134ec4928d99b4f4b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 17:11:31 +0200 Subject: [PATCH] feat(admin): multi-mentor stacking UI + change-request inbox (PR8 Task 8) - /admin/projects/[id]/mentor renders all co-mentors as a list with per-row Unassign (confirm dialog) and a stacking "Add a mentor" flow that no longer hides when at least one mentor is assigned. Candidates and AI suggestions filter out already-assigned mentors. - Pending change-requests panel appears above the mentor list when there are open requests for the project, with per-card Mark Resolved / Dismiss actions routed through mentor.resolveChangeRequest (optional resolution note). - MentoringRoundOverview gains a "Pending change requests" row showing the PENDING count across the program; the Review link deep-links to the first pending request's project mentor page. - mentor.unassign now accepts { assignmentId } so the admin UI can target a specific co-mentor (legacy { projectId }-only callers still work and remove the most-recent assignment). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/projects/[id]/mentor/page.tsx | 962 ++++++++++++------ .../admin/round/mentoring-round-overview.tsx | 50 + src/server/routers/mentor.ts | 48 +- 3 files changed, 744 insertions(+), 316 deletions(-) diff --git a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx index 5a0b715..4952791 100644 --- a/src/app/(admin)/admin/projects/[id]/mentor/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/mentor/page.tsx @@ -18,6 +18,8 @@ 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 { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, @@ -27,15 +29,35 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { AlertTriangle, ArrowLeft, Bot, Check, + Inbox, Loader2, Search, Sparkles, Users, + UserPlus, } from 'lucide-react' import { getInitials, formatEnumLabel } from '@/lib/utils' @@ -48,14 +70,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { const utils = trpc.useUtils() const [search, setSearch] = useState('') const [pendingMentorId, setPendingMentorId] = useState(null) + const [unassignTarget, setUnassignTarget] = useState<{ + assignmentId: string + mentorName: string + } | null>(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 }, + // Already-assigned mentors (full list). Project.get spreads the underlying + // `mentorAssignments` relation so we can read it directly. + const assignedMentorAssignments = useMemo(() => { + if (!project) return [] + // The Prisma relation is included via `...project` spread; type comes + // through the tRPC client. + type Assignment = NonNullable['mentorAssignments'][number] + return ((project as unknown as { mentorAssignments?: Assignment[] }).mentorAssignments ?? []).filter( + (a) => !a.droppedAt, ) + }, [project]) + const assignedMentorIds = useMemo( + () => new Set(assignedMentorAssignments.map((a) => a.mentorId)), + [assignedMentorAssignments], + ) + + const { data: candidatesData, isLoading: candidatesLoading } = + trpc.mentor.getCandidates.useQuery({ projectId }, { enabled: !!project }) const { data: suggestionsData, @@ -63,12 +102,12 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { refetch: refetchSuggestions, } = trpc.mentor.getSuggestions.useQuery( { projectId, limit: 5 }, - { enabled: !!project && !project.mentorAssignment }, + { enabled: !!project }, ) const assignMutation = trpc.mentor.assign.useMutation({ onSuccess: () => { - toast.success('Mentor assigned') + toast.success('Mentor added') utils.project.get.invalidate({ id: projectId }) utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId }) @@ -86,21 +125,31 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { utils.project.get.invalidate({ id: projectId }) utils.mentor.getCandidates.invalidate({ projectId }) utils.mentor.getSuggestions.invalidate({ projectId }) + setUnassignTarget(null) + }, + onError: (err) => { + toast.error(err.message) + setUnassignTarget(null) }, - onError: (err) => toast.error(err.message), }) const filteredCandidates = useMemo(() => { if (!candidatesData) return [] + const base = candidatesData.candidates.filter((c) => !assignedMentorIds.has(c.id)) const q = search.trim().toLowerCase() - if (!q) return candidatesData.candidates - return candidatesData.candidates.filter((c) => { + if (!q) return base + return base.filter((c) => { const hay = [c.name ?? '', c.email, ...(c.expertiseTags ?? []), c.country ?? ''] .join(' ') .toLowerCase() return hay.includes(q) }) - }, [candidatesData, search]) + }, [candidatesData, search, assignedMentorIds]) + + const filteredSuggestions = useMemo(() => { + if (!suggestionsData) return [] + return suggestionsData.suggestions.filter((s) => !assignedMentorIds.has(s.mentorId)) + }, [suggestionsData, assignedMentorIds]) if (projectLoading) return if (!project) { @@ -113,7 +162,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { ) } - const hasMentor = !!project.mentorAssignment const teamSize = project.teamMembers?.length ?? 0 const aiSource = suggestionsData?.source ?? 'ai' @@ -206,320 +254,632 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) { + {/* ─── Pending Change Requests ─── */} + + {/* ─── Currently Assigned ─── */} Currently Assigned + + {assignedMentorAssignments.length === 0 + ? 'No mentors assigned yet' + : `${assignedMentorAssignments.length} mentor${ + assignedMentorAssignments.length === 1 ? '' : 's' + } on this team`} + - {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, ' ')} - - -
+ {assignedMentorAssignments.length === 0 ? ( +
+ +

+ No mentors assigned yet — add one below. +

) : ( -

- No mentor assigned yet — pick one below. -

+
    + {assignedMentorAssignments.map((a) => { + const m = a.mentor + const tags = m.expertiseTags ?? [] + return ( +
  • +
    + + + {getInitials(m.name || m.email)} + + +
    + + {m.name || 'Unnamed'} + +

    {m.email}

    + {tags.length > 0 && ( +
    + {tags.slice(0, 5).map((tag: string) => ( + + {tag} + + ))} + {tags.length > 5 && ( + + +{tags.length - 5} + + )} +
    + )} +

    + Assigned{' '} + {new Date(a.assignedAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

    +
    +
    +
    + + {a.method.replace(/_/g, ' ')} + + +
    +
  • + ) + })} +
)} - {/* ─── Pick a Mentor ─── */} - {!hasMentor && ( - - - Pick a Mentor - - Browse all eligible mentors or use AI to surface the best fits. - - - - - - - Manual Picker - - - AI Suggestions - - + {/* ─── Add a Mentor ─── */} + + + + + Add a Mentor + + + Stack additional mentors on this team. 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" - /> + +
+ + setSearch(e.target.value)} + placeholder="Search by name, email, country, or expertise tag…" + className="pl-9" + /> +
+ {candidatesLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))}
- {candidatesLoading ? ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- ) : filteredCandidates.length === 0 ? ( -
- No matching mentors. Try a different search. -
- ) : ( -
- - - - Mentor - Expertise - Country - Load - Overlap - + ) : filteredCandidates.length === 0 ? ( +
+ {assignedMentorIds.size > 0 && search.trim() === '' + ? 'All eligible mentors are already assigned.' + : '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)}% + + + + +
- - - {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) => ( - - ))} + )} + + + + {aiSource === 'fallback' && ( +
+ +
+

AI matching unavailable

+

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

- ) : !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'} - +
+ )} +
+ +
+ {suggestionsLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : filteredSuggestions.length === 0 ? ( +

+ {assignedMentorIds.size > 0 + ? 'All top suggestions are already assigned.' + : 'No suggestions available.'} +

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

{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}" -

- )} -
-
- +
+
+
+

{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}" +

+ )} +
- ))} -
- )} - - - - - )} + +
+ ))} +
+ )} + + + + + + {/* ─── Unassign confirm ─── */} + { + if (!open) setUnassignTarget(null) + }} + > + + + Unassign mentor? + + {unassignTarget + ? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.` + : ''} + + + + + Cancel + + { + e.preventDefault() + if (!unassignTarget) return + unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId }) + }} + disabled={unassignMutation.isPending} + > + {unassignMutation.isPending ? ( + + ) : ( + 'Unassign' + )} + + + +
) } +// ───────────────────────────────────────────────────────────────────────────── +// Pending Change Requests panel +// ───────────────────────────────────────────────────────────────────────────── + +function PendingChangeRequestsPanel({ projectId }: { projectId: string }) { + const utils = trpc.useUtils() + const { data: requests, isLoading } = trpc.mentor.listChangeRequests.useQuery({ + projectId, + status: 'PENDING', + }) + + const [resolveTarget, setResolveTarget] = useState<{ + id: string + status: 'RESOLVED' | 'DISMISSED' + requesterName: string + } | null>(null) + const [resolutionNote, setResolutionNote] = useState('') + + const resolveMutation = trpc.mentor.resolveChangeRequest.useMutation({ + onSuccess: (_, variables) => { + toast.success( + `Request marked ${variables.status === 'RESOLVED' ? 'resolved' : 'dismissed'}`, + ) + utils.mentor.listChangeRequests.invalidate() + setResolveTarget(null) + setResolutionNote('') + }, + onError: (err) => toast.error(err.message), + }) + + if (isLoading) { + return ( + + + + + Pending change requests + + + + + + + ) + } + + if (!requests || requests.length === 0) { + return null + } + + return ( + <> + + + + + Pending change requests + + {requests.length} + + + + Team members or mentors have asked admin to change a mentor on this team. + + + +
    + {requests.map((r) => ( + + setResolveTarget({ + id: r.id, + status, + requesterName: + r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown', + }) + } + /> + ))} +
+
+
+ + { + if (!open) { + setResolveTarget(null) + setResolutionNote('') + } + }} + > + + + + {resolveTarget?.status === 'RESOLVED' + ? 'Mark request resolved' + : 'Dismiss request'} + + + {resolveTarget?.status === 'RESOLVED' + ? `You've taken action on the request from ${resolveTarget?.requesterName}. Optionally add a note explaining what was done.` + : `Close the request from ${resolveTarget?.requesterName} without action. Optionally add a note explaining why.`} + + +
+ +