merge: PR8 Task 8 — admin multi-mentor UI + change-request inbox

This commit is contained in:
Matt
2026-05-22 17:13:02 +02:00
3 changed files with 744 additions and 316 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import {
ArrowRight, ArrowRight,
Clock, Clock,
FileText, FileText,
Inbox,
MessageCircle, MessageCircle,
Target, Target,
UserCheck, UserCheck,
@@ -48,6 +49,10 @@ export function MentoringRoundOverview({ roundId }: Props) {
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({}) const { data: pool, isLoading: poolLoading } = trpc.mentor.getMentorPool.useQuery({})
const { data: pendingChangeRequests } = trpc.mentor.listChangeRequests.useQuery(
{ status: 'PENDING' },
{ refetchInterval: 30_000 },
)
if (statsLoading || poolLoading) { if (statsLoading || poolLoading) {
return ( return (
@@ -60,6 +65,15 @@ export function MentoringRoundOverview({ roundId }: Props) {
} }
if (!stats || !pool) return null 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 const requestedPct = stats.totalProjects
? Math.round((stats.requestedCount / stats.totalProjects) * 100) ? Math.round((stats.requestedCount / stats.totalProjects) * 100)
: 0 : 0
@@ -173,6 +187,42 @@ export function MentoringRoundOverview({ roundId }: Props) {
</CardContent> </CardContent>
</Card> </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"> <Card className="md:col-span-2 xl:col-span-4">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">Workspace activity</CardTitle> <CardTitle className="text-sm">Workspace activity</CardTitle>

View File

@@ -565,27 +565,45 @@ 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 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 }) => { .mutation(async ({ ctx, input }) => {
// TODO(PR8 Task 8): admin UI should specify which mentor to drop when const assignment = input.assignmentId
// multiple are assigned. Legacy callers pass only projectId — we resolve ? await ctx.prisma.mentorAssignment.findUnique({
// to the most-recent assignment for backward compatibility. where: { id: input.assignmentId },
const assignment = await ctx.prisma.mentorAssignment.findFirst({ include: {
where: { projectId: input.projectId }, mentor: { select: { id: true, name: true } },
orderBy: { assignedAt: 'desc' }, project: { select: { id: true, title: true } },
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 } },
project: { select: { id: true, title: true } },
},
})
if (!assignment) { if (!assignment) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', 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', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: assignment.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: assignment.project.id,
projectTitle: assignment.project.title, projectTitle: assignment.project.title,
mentorId: assignment.mentor.id, mentorId: assignment.mentor.id,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,