feat: Mentees & Activity tab on /admin/mentors
Adds a project-centric ops view for mentor management: - New mentor.getMenteeActivity tRPC procedure aggregates every project with wantsMentorship=true and derives a status (unassigned / assigned / active / stalled) from the latest message + file activity. - /admin/mentors becomes a tabbed page: existing Mentor list + new Mentees & Activity table with status pills, search, and a per-row Assign/Open CTA linking to /admin/projects/[id]/mentor. - Includes 2 unit tests covering classification + program scoping. Also: ignore .remember/ (plugin scratch dir).
This commit is contained in:
@@ -1072,6 +1072,125 @@ export const mentorRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Project-centric activity view: every project that wants mentorship,
|
||||
* with assignment status, latest activity timestamps, and a derived
|
||||
* status (unassigned / assigned / active / stalled).
|
||||
* Drives the "Mentees & Activity" tab on /admin/mentors.
|
||||
*/
|
||||
getMenteeActivity: adminProcedure
|
||||
.input(z.object({ programId: z.string().optional() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: {
|
||||
wantsMentorship: true,
|
||||
...(input.programId ? { programId: input.programId } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
country: true,
|
||||
status: true,
|
||||
oceanIssue: true,
|
||||
competitionCategory: true,
|
||||
mentorAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
method: true,
|
||||
assignedAt: true,
|
||||
completionStatus: true,
|
||||
mentor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
maxAssignments: true,
|
||||
_count: { select: { mentorAssignments: true } },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
files: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { createdAt: true },
|
||||
},
|
||||
_count: { select: { messages: true, files: true } },
|
||||
},
|
||||
},
|
||||
teamMembers: {
|
||||
where: { role: 'LEAD' },
|
||||
take: 1,
|
||||
select: { user: { select: { name: true, email: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: { title: 'asc' },
|
||||
})
|
||||
|
||||
const ACTIVE_WINDOW_MS = 7 * 86_400_000
|
||||
const STALLED_WINDOW_MS = 14 * 86_400_000
|
||||
const now = Date.now()
|
||||
|
||||
const totals = { unassigned: 0, assigned: 0, active: 0, stalled: 0 }
|
||||
|
||||
const rows = projects.map((p) => {
|
||||
const ma = p.mentorAssignment
|
||||
const lastMessageAt = ma?.messages[0]?.createdAt ?? null
|
||||
const lastFileAt = ma?.files[0]?.createdAt ?? null
|
||||
const lastActivityAt = [lastMessageAt, lastFileAt]
|
||||
.filter((d): d is Date => d != null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0] ?? null
|
||||
|
||||
let status: 'unassigned' | 'assigned' | 'active' | 'stalled'
|
||||
if (!ma) {
|
||||
status = 'unassigned'
|
||||
} else if (lastActivityAt && now - lastActivityAt.getTime() <= ACTIVE_WINDOW_MS) {
|
||||
status = 'active'
|
||||
} else {
|
||||
const referenceTime = lastActivityAt ?? ma.assignedAt
|
||||
const elapsed = now - referenceTime.getTime()
|
||||
status = elapsed > STALLED_WINDOW_MS ? 'stalled' : 'assigned'
|
||||
}
|
||||
totals[status]++
|
||||
|
||||
const teamLead = p.teamMembers[0]?.user ?? null
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
country: p.country,
|
||||
status: p.status,
|
||||
oceanIssue: p.oceanIssue,
|
||||
competitionCategory: p.competitionCategory,
|
||||
},
|
||||
teamLead: teamLead ? { name: teamLead.name, email: teamLead.email } : null,
|
||||
mentor: ma?.mentor
|
||||
? {
|
||||
id: ma.mentor.id,
|
||||
name: ma.mentor.name,
|
||||
email: ma.mentor.email,
|
||||
currentLoad: ma.mentor._count.mentorAssignments,
|
||||
maxAssignments: ma.mentor.maxAssignments,
|
||||
}
|
||||
: null,
|
||||
assignmentMethod: ma?.method ?? null,
|
||||
assignedAt: ma?.assignedAt ?? null,
|
||||
lastMessageAt,
|
||||
lastFileAt,
|
||||
lastActivityAt,
|
||||
messageCount: ma?._count.messages ?? 0,
|
||||
fileCount: ma?._count.files ?? 0,
|
||||
status,
|
||||
}
|
||||
})
|
||||
|
||||
return { rows, totals }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get mentor's assigned projects
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user