feat(mentor): round-level auto-fill toolbar on Projects tab (§C)

Adds an 'Auto-fill remaining' button above ProjectStatesTable on the
MENTORING round Projects tab. Calls mentor.autoAssignBulkForRound,
respecting the round's configJson.eligibility:
  - requested_only / all_advancing: enabled, count from new
    round.getProjectsNeedingMentor query
  - admin_selected: disabled with explanatory copy

Plan: docs/superpowers/plans/2026-04-28-pr4-mentor-assignment-ux.md
This commit is contained in:
Matt
2026-04-28 14:58:32 +02:00
parent ddae34c8f5
commit 2b07c12c18
2 changed files with 99 additions and 0 deletions

View File

@@ -145,6 +145,73 @@ const stateColors: Record<string, string> = Object.fromEntries(
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg]) Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
) )
// ═══════════════════════════════════════════════════════════════════════════
// Mentoring round: Auto-fill remaining toolbar (Projects tab)
// ═══════════════════════════════════════════════════════════════════════════
function MentoringBulkAssignToolbar({
roundId,
configJson,
}: {
roundId: string
configJson: Record<string, unknown>
}) {
const utils = trpc.useUtils()
const eligibility = (configJson.eligibility as string) ?? 'requested_only'
const isAdminSelected = eligibility === 'admin_selected'
const { data: pending } = trpc.round.getProjectsNeedingMentor.useQuery(
{ roundId },
{ enabled: !isAdminSelected, refetchInterval: 30_000 },
)
const count = pending?.count ?? 0
const bulk = trpc.mentor.autoAssignBulkForRound.useMutation({
onSuccess: (result) => {
toast.success(result.message)
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.project.list.invalidate()
},
onError: (err) => toast.error(err.message),
})
const eligibilityLabel = eligibility.replace('_', ' ')
return (
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-4 py-2.5">
<div className="text-sm">
{isAdminSelected ? (
<>
<span className="font-medium">Eligibility: admin-selected</span>
<span className="text-muted-foreground ml-2">
auto-fill is disabled. Assign each project manually.
</span>
</>
) : count > 0 ? (
<>
<span className="font-medium">{count}</span>{' '}
<span className="text-muted-foreground">
project{count === 1 ? '' : 's'} eligible for auto-fill ({eligibilityLabel})
</span>
</>
) : (
<span className="text-muted-foreground">
All eligible projects have a mentor.
</span>
)}
</div>
<Button
size="sm"
onClick={() => bulk.mutate({ roundId })}
disabled={isAdminSelected || count === 0 || bulk.isPending}
>
{bulk.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Auto-fill remaining
</Button>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Main Page Component // Main Page Component
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -1477,6 +1544,9 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */} {/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4"> <TabsContent value="projects" className="space-y-4">
{isMentoring && (
<MentoringBulkAssignToolbar roundId={roundId} configJson={config} />
)}
<ProjectStatesTable <ProjectStatesTable
competitionId={competitionId} competitionId={competitionId}
roundId={roundId} roundId={roundId}

View File

@@ -215,6 +215,35 @@ export const roundRouter = router({
) )
}), }),
/**
* Count projects in a MENTORING round eligible for mentor auto-fill
* (i.e., no mentorAssignment, scoped by configJson.eligibility).
* Used by the Auto-fill remaining toolbar on the round Projects tab.
*/
getProjectsNeedingMentor: 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 { count: 0 }
const config = (round.configJson ?? {}) as Record<string, unknown>
const eligibility = (config.eligibility as string) ?? 'requested_only'
if (eligibility === 'admin_selected') return { count: 0 }
const count = await ctx.prisma.projectRoundState.count({
where: {
roundId: input.roundId,
project: {
mentorAssignment: null,
...(eligibility === 'requested_only' ? { wantsMentorship: true } : {}),
},
},
})
return { count }
}),
/** /**
* Delete a round * Delete a round
*/ */