diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index 73fd674..357d650 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -976,17 +976,39 @@ export default function MemberDetailPage() { Cancel { + onClick={async () => { if (!pendingAdditionalRole) return const { role: r, action } = pendingAdditionalRole - if (action === 'add') { - setAdditionalRoles((prev) => - prev.includes(r) ? prev : [...prev, r] + const nextAdditional = + action === 'add' + ? additionalRoles.includes(r) + ? additionalRoles + : [...additionalRoles, r] + : additionalRoles.filter((x) => x !== r) + const nextAllRoles = [ + role, + ...nextAdditional.filter((x) => x !== role), + ] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'> + try { + await updateUser.mutateAsync({ + id: userId, + roles: nextAllRoles, + }) + setAdditionalRoles(nextAdditional) + utils.user.get.invalidate({ id: userId }) + utils.user.list.invalidate() + toast.success( + action === 'add' + ? `${r.replace(/_/g, ' ')} role added` + : `${r.replace(/_/g, ' ')} role removed`, ) - } else { - setAdditionalRoles((prev) => prev.filter((x) => x !== r)) + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to update roles', + ) + } finally { + setPendingAdditionalRole(null) } - setPendingAdditionalRole(null) }} > {pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'} diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 6b0a945..b444a35 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -92,6 +92,7 @@ import { ProjectStatesTable } from '@/components/admin/round/project-states-tabl import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' +import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' @@ -168,6 +169,10 @@ function MentoringBulkAssignToolbar({ { enabled: !isAdminSelected, refetchInterval: 30_000 }, ) const count = pending?.count ?? 0 + const eligibleTotal = pending?.eligibleTotal ?? 0 + const mentorPoolSize = pending?.mentorPoolSize ?? 0 + const hasNoMentors = mentorPoolSize === 0 + const hasNoEligible = eligibleTotal === 0 const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({ onSuccess: (result) => { @@ -190,23 +195,41 @@ function MentoringBulkAssignToolbar({ — auto-fill is disabled. Assign each project manually. + ) : hasNoMentors ? ( + + No mentors in the pool yet —{' '} + + add mentors + {' '} + before auto-filling. + + ) : hasNoEligible ? ( + + No projects are eligible for mentorship in this round ( + {eligibilityLabel}). + ) : count > 0 ? ( <> {count}{' '} - project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel}) + of {eligibleTotal} project{eligibleTotal === 1 ? '' : 's'} still + needs a mentor ({eligibilityLabel}) ) : ( - All eligible projects have a mentor. + All {eligibleTotal} eligible project{eligibleTotal === 1 ? '' : 's'}{' '} + already have a mentor. )} + ) + + return ( +
+
+
+ + + + +
+
+ + setSearch(e.target.value)} + placeholder="Search projects, teams, or mentors…" + className="pl-8" + /> +
+
+ +
+ + + + Project + Wants? + Mentors + Action + + + + {filtered.length === 0 ? ( + + + No projects match the current filter. + + + ) : ( + filtered.map((p) => ( + + +
{p.title}
+
+ {p.teamName ?? '—'} + {p.country && ( + <> + {' · '} + + + )} +
+
+ +
+ {p.wantsMentorship ? ( + + Requested + + ) : ( + No + )} + {p.finalistConfirmationStatus !== 'CONFIRMED' && ( + + {p.finalistConfirmationStatus + ? p.finalistConfirmationStatus.toLowerCase() + : 'no confirmation'} + + )} +
+
+ + {p.mentors.length === 0 ? ( + + Unassigned + + ) : ( +
+ {p.mentors.map((m) => ( + + {(m.method === 'AI_AUTO' || + m.method === 'AI_SUGGESTED') && ( + + )} + {m.name ?? m.email} + + ))} +
+ )} +
+ + + +
+ )) + )} +
+
+
+
+ ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 2d74e31..b37c939 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -34,7 +34,7 @@ const DialogContent = React.forwardRef< { + // Hard guard: never send real email from the test runner. This is a belt-and- + // braces check on top of the vitest-level mock in tests/setup.ts. Vitest sets + // NODE_ENV='test' and exposes VITEST=true automatically. + if (process.env.NODE_ENV === 'test' || process.env.VITEST === 'true') { + return + } const { transporter, from } = await getTransporter() const to = DEV_EMAIL_OVERRIDE || opts.to const subject = DEV_EMAIL_OVERRIDE ? `[DEV → ${opts.to}] ${opts.subject}` : opts.subject diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index c2b2686..3684098 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -818,7 +818,7 @@ export const mentorRouter = router({ where: { roundId: input.roundId, project: { - mentorAssignments: { none: {} }, + mentorAssignments: { none: { droppedAt: null } }, // Only assign mentors to projects whose team has confirmed they will // attend the grand finale. This skips PENDING/DECLINED/EXPIRED/SUPERSEDED // confirmations and any project without a confirmation row at all. diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 1f5eb13..c735da0 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -227,21 +227,120 @@ export const roundRouter = router({ where: { id: input.roundId }, select: { roundType: true, configJson: true }, }) - if (round.roundType !== 'MENTORING') return { count: 0 } + if (round.roundType !== 'MENTORING') { + return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 } + } const config = (round.configJson ?? {}) as Record const eligibility = (config.eligibility as string) ?? 'requested_only' - if (eligibility === 'admin_selected') return { count: 0 } + if (eligibility === 'admin_selected') { + return { count: 0, eligibleTotal: 0, mentorPoolSize: 0 } + } - const count = await ctx.prisma.projectRoundState.count({ - where: { - roundId: input.roundId, + const eligibilityWhere = + eligibility === 'requested_only' ? { wantsMentorship: true } : {} + + // Mirror autoAssignBulkForRound's filter exactly so the toolbar count + // matches what the auto-fill button will actually process. + const autoFillWhere = { + mentorAssignments: { none: { droppedAt: null } }, + finalistConfirmation: { status: 'CONFIRMED' as const }, + ...eligibilityWhere, + } + const [count, eligibleTotal, mentorPoolSize] = await Promise.all([ + ctx.prisma.projectRoundState.count({ + where: { roundId: input.roundId, project: autoFillWhere }, + }), + ctx.prisma.projectRoundState.count({ + where: { + roundId: input.roundId, + project: { + finalistConfirmation: { status: 'CONFIRMED' as const }, + ...eligibilityWhere, + }, + }, + }), + ctx.prisma.user.count({ + where: { roles: { has: 'MENTOR' }, status: { not: 'SUSPENDED' } }, + }), + ]) + return { count, eligibleTotal, mentorPoolSize } + }), + + /** + * List projects in a MENTORING round with their (multi-)mentor assignments. + * Drives the per-team assignment table on the round Projects tab so admins + * can see who is assigned to whom and add/swap mentors per project. + */ + listMentoringProjects: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { roundType: true, configJson: true }, + }) + if (round.roundType !== 'MENTORING') return { projects: [] } + + const config = (round.configJson ?? {}) as Record + const eligibility = (config.eligibility as string) ?? 'requested_only' + + const states = await ctx.prisma.projectRoundState.findMany({ + where: { roundId: input.roundId }, + select: { + state: true, project: { - mentorAssignments: { none: {} }, - ...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}), + select: { + id: true, + title: true, + teamName: true, + country: true, + wantsMentorship: true, + competitionCategory: true, + finalistConfirmation: { select: { status: true } }, + mentorAssignments: { + where: { droppedAt: null }, + select: { + id: true, + method: true, + assignedAt: true, + mentor: { select: { id: true, name: true, email: true } }, + }, + orderBy: { assignedAt: 'asc' }, + }, + }, }, }, + orderBy: [{ project: { title: 'asc' } }], }) - return { count } + + return { + eligibility, + projects: states.map((s) => { + const isEligible = + eligibility === 'all_in_round' || + eligibility === 'admin_selected' || + s.project.wantsMentorship + return { + id: s.project.id, + title: s.project.title, + teamName: s.project.teamName, + country: s.project.country, + competitionCategory: s.project.competitionCategory, + wantsMentorship: s.project.wantsMentorship, + finalistConfirmationStatus: + s.project.finalistConfirmation?.status ?? null, + isEligible, + state: s.state, + mentors: s.project.mentorAssignments.map((a) => ({ + assignmentId: a.id, + method: a.method, + assignedAt: a.assignedAt, + id: a.mentor.id, + name: a.mentor.name, + email: a.mentor.email, + })), + } + }), + } }), /**