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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-22 17:11:31 +02:00
parent ee47c0305f
commit 83e950bb67
3 changed files with 744 additions and 316 deletions

View File

@@ -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<string | null>(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<typeof project>['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 <MentorAssignmentSkeleton />
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,80 +254,112 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
{/* ─── Pending Change Requests ─── */}
<PendingChangeRequestsPanel projectId={projectId} />
{/* ─── Currently Assigned ─── */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Currently Assigned</CardTitle>
<CardDescription>
{assignedMentorAssignments.length === 0
? 'No mentors assigned yet'
: `${assignedMentorAssignments.length} mentor${
assignedMentorAssignments.length === 1 ? '' : 's'
} on this team`}
</CardDescription>
</CardHeader>
<CardContent>
{hasMentor ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{assignedMentorAssignments.length === 0 ? (
<div className="rounded-md border border-dashed py-8 text-center">
<Users className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
No mentors assigned yet add one below.
</p>
</div>
) : (
<ul className="divide-y">
{assignedMentorAssignments.map((a) => {
const m = a.mentor
const tags = m.expertiseTags ?? []
return (
<li
key={a.id}
className="flex items-start justify-between gap-4 py-4 first:pt-0 last:pb-0"
>
<div className="flex flex-1 items-start gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(
project.mentorAssignment!.mentor.name ||
project.mentorAssignment!.mentor.email,
)}
{getInitials(m.name || m.email)}
</AvatarFallback>
</Avatar>
<div>
<div className="min-w-0 flex-1">
<Link
href={`/admin/mentors/${project.mentorAssignment!.mentor.id}`}
href={`/admin/mentors/${m.id}`}
className="font-medium hover:underline"
>
{project.mentorAssignment!.mentor.name || 'Unnamed'}
{m.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 && (
<p className="text-muted-foreground text-sm">{m.email}</p>
{tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{project.mentorAssignment!.mentor.expertiseTags
.slice(0, 5)
.map((tag: string) => (
{tags.slice(0, 5).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{tags.length > 5 && (
<Badge variant="outline" className="text-xs">
+{tags.length - 5}
</Badge>
)}
</div>
)}
<p className="text-muted-foreground mt-2 text-xs">
Assigned{' '}
{new Date(a.assignedAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-xs">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
{a.method.replace(/_/g, ' ')}
</Badge>
<Button
variant="destructive"
variant="outline"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
onClick={() =>
setUnassignTarget({
assignmentId: a.id,
mentorName: m.name || m.email,
})
}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
Unassign
</Button>
</div>
</div>
) : (
<p className="text-muted-foreground text-sm">
No mentor assigned yet pick one below.
</p>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
{/* ─── Pick a Mentor ─── */}
{!hasMentor && (
{/* ─── Add a Mentor ─── */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Pick a Mentor</CardTitle>
<CardTitle className="text-lg flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Add a Mentor
</CardTitle>
<CardDescription>
Browse all eligible mentors or use AI to surface the best fits.
Stack additional mentors on this team. Browse all eligible mentors or use AI to surface the best fits.
</CardDescription>
</CardHeader>
<CardContent>
@@ -311,7 +391,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</div>
) : filteredCandidates.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
No matching mentors. Try a different search.
{assignedMentorIds.size > 0 && search.trim() === ''
? 'All eligible mentors are already assigned.'
: 'No matching mentors. Try a different search.'}
</div>
) : (
<div className="overflow-hidden rounded-md border">
@@ -376,7 +458,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-3.5 w-3.5" /> Assign
<Check className="mr-1 h-3.5 w-3.5" /> Add
</>
)}
</Button>
@@ -422,13 +504,15 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : !suggestionsData || suggestionsData.suggestions.length === 0 ? (
) : filteredSuggestions.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
No suggestions available.
{assignedMentorIds.size > 0
? 'All top suggestions are already assigned.'
: 'No suggestions available.'}
</p>
) : (
<div className="space-y-3">
{suggestionsData.suggestions.map((s, i) => (
{filteredSuggestions.map((s, i) => (
<div
key={s.mentorId}
className="flex items-start justify-between rounded-md border p-4"
@@ -503,7 +587,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-3.5 w-3.5" /> Assign
<Check className="mr-1 h-3.5 w-3.5" /> Add
</>
)}
</Button>
@@ -515,8 +599,284 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</Tabs>
</CardContent>
</Card>
{/* ─── Unassign confirm ─── */}
<AlertDialog
open={!!unassignTarget}
onOpenChange={(open) => {
if (!open) setUnassignTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unassign mentor?</AlertDialogTitle>
<AlertDialogDescription>
{unassignTarget
? `Remove ${unassignTarget.mentorName} from this team? Other co-mentors will remain.`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={unassignMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
if (!unassignTarget) return
unassignMutation.mutate({ assignmentId: unassignTarget.assignmentId })
}}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Unassign'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5" />
Pending change requests
</CardTitle>
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
)
}
if (!requests || requests.length === 0) {
return null
}
return (
<>
<Card className="border-amber-300 dark:border-amber-700">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Inbox className="h-5 w-5 text-amber-600" />
Pending change requests
<Badge variant="secondary" className="ml-1">
{requests.length}
</Badge>
</CardTitle>
<CardDescription>
Team members or mentors have asked admin to change a mentor on this team.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{requests.map((r) => (
<ChangeRequestRow
key={r.id}
request={r}
onResolve={(status) =>
setResolveTarget({
id: r.id,
status,
requesterName:
r.requestedBy?.name ?? r.requestedBy?.email ?? 'Unknown',
})
}
/>
))}
</ul>
</CardContent>
</Card>
<Dialog
open={!!resolveTarget}
onOpenChange={(open) => {
if (!open) {
setResolveTarget(null)
setResolutionNote('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{resolveTarget?.status === 'RESOLVED'
? 'Mark request resolved'
: 'Dismiss request'}
</DialogTitle>
<DialogDescription>
{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.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="resolution-note">Resolution note (optional)</Label>
<Textarea
id="resolution-note"
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="e.g. Replaced Jane with John based on expertise mismatch."
rows={4}
maxLength={2000}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setResolveTarget(null)
setResolutionNote('')
}}
disabled={resolveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => {
if (!resolveTarget) return
resolveMutation.mutate({
id: resolveTarget.id,
status: resolveTarget.status,
resolutionNote: resolutionNote.trim() || undefined,
})
}}
disabled={resolveMutation.isPending}
>
{resolveMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : resolveTarget?.status === 'RESOLVED' ? (
'Mark Resolved'
) : (
'Dismiss'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
type ChangeRequestRowProps = {
request: {
id: string
reason: string
createdAt: Date
requestedBy: { id: string; name: string | null; email: string } | null
targetAssignment: {
id: string
mentor: { id: string; name: string | null; email: string }
} | null
}
onResolve: (status: 'RESOLVED' | 'DISMISSED') => void
}
function ChangeRequestRow({ request, onResolve }: ChangeRequestRowProps) {
const [expanded, setExpanded] = useState(false)
const reasonIsLong = request.reason.length > 240
return (
<li className="rounded-md border bg-card p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="font-medium">
{request.requestedBy?.name ?? request.requestedBy?.email ?? 'Unknown'}
</span>
{request.requestedBy?.email && request.requestedBy.name && (
<span className="text-muted-foreground text-xs">
{request.requestedBy.email}
</span>
)}
<span className="text-muted-foreground text-xs">
·{' '}
{new Date(request.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
{request.targetAssignment && (
<div className="text-muted-foreground text-xs">
About:{' '}
<span className="font-medium">
{request.targetAssignment.mentor.name ||
request.targetAssignment.mentor.email}
</span>
</div>
)}
<p
className={
expanded || !reasonIsLong
? 'text-sm whitespace-pre-wrap'
: 'text-sm whitespace-pre-wrap line-clamp-4'
}
>
{request.reason}
</p>
{reasonIsLong && (
<button
type="button"
className="text-primary text-xs hover:underline"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
<div className="flex shrink-0 flex-col gap-2">
<Button size="sm" onClick={() => onResolve('RESOLVED')}>
Mark Resolved
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onResolve('DISMISSED')}
>
Dismiss
</Button>
</div>
</div>
</li>
)
}

View File

@@ -9,6 +9,7 @@ import {
ArrowRight,
Clock,
FileText,
Inbox,
MessageCircle,
Target,
UserCheck,
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
{ refetchInterval: 30_000 },
)
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) {
return (
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
}
if (!stats || !pool) return null
const pendingCount = pendingChangeRequests?.length ?? 0
// If there's at least one pending request, deep-link directly into the
// first one's project (admins can resolve / view siblings from there).
// Otherwise the card stays static.
const firstPendingProjectId = pendingChangeRequests?.[0]?.project.id ?? null
const changeRequestsHref = firstPendingProjectId
? `/admin/projects/${firstPendingProjectId}/mentor`
: null
const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
</CardContent>
</Card>
<Card
className={`md:col-span-2 xl:col-span-4 ${
pendingCount > 0 ? 'border-amber-300 dark:border-amber-700' : ''
}`}
>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Inbox
className={`h-5 w-5 ${
pendingCount > 0 ? 'text-amber-600' : 'text-muted-foreground'
}`}
/>
<div>
<div className="text-sm font-medium">Pending change requests</div>
<div className="text-muted-foreground text-xs">
Team members asking admin to swap a mentor
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-2xl font-bold tabular-nums">{pendingCount}</div>
{changeRequestsHref ? (
<Link
href={changeRequestsHref}
className="text-muted-foreground hover:text-foreground inline-flex items-center text-xs"
>
Review
<ArrowRight className="ml-0.5 h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground text-xs">All clear</span>
)}
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle>

View File

@@ -565,16 +565,34 @@ export const mentorRouter = router({
}),
/**
* Remove mentor assignment
* Remove mentor assignment.
*
* Multi-mentor (PR8): callers should pass `assignmentId` to target a
* specific co-mentor. Legacy callers passing only `projectId` get the
* most-recent assignment removed (kept for backward compatibility).
*/
unassign: adminProcedure
.input(z.object({ projectId: z.string() }))
.input(
z
.object({
assignmentId: z.string().optional(),
projectId: z.string().optional(),
})
.refine((v) => !!v.assignmentId || !!v.projectId, {
message: 'Either assignmentId or projectId is required',
}),
)
.mutation(async ({ ctx, input }) => {
// TODO(PR8 Task 8): admin UI should specify which mentor to drop when
// multiple are assigned. Legacy callers pass only projectId — we resolve
// to the most-recent assignment for backward compatibility.
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId },
const assignment = input.assignmentId
? await ctx.prisma.mentorAssignment.findUnique({
where: { id: input.assignmentId },
include: {
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true } },
},
})
: await ctx.prisma.mentorAssignment.findFirst({
where: { projectId: input.projectId! },
orderBy: { assignedAt: 'desc' },
include: {
mentor: { select: { id: true, name: true } },
@@ -585,7 +603,7 @@ export const mentorRouter = router({
if (!assignment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No mentor assignment found for this project',
message: 'No mentor assignment found',
})
}
@@ -602,7 +620,7 @@ export const mentorRouter = router({
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectId: assignment.project.id,
projectTitle: assignment.project.title,
mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name,