feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s

mentor.bulkAssign now accepts mentorIds[] instead of a single mentorId and
creates the cartesian product of (mentor, project) assignments. Existing
active (mentor, project) pairs are skipped per-pair, not per-project, so
choosing two mentors against a project that already has one of them still
adds the second.

Email coalescing stays one-per-mentor: each mentor receives a single email
listing only their own newly-assigned projects (not the union). Each touched
project still triggers a single team-introduction email when its MENTORING
round is ROUND_ACTIVE, listing all currently-active mentors on that team.

Dialog UI swaps the radio picker for a checkbox group with a removable chip
strip for selected mentors, a live preview of the assignment count
(mentors × projects = up to N), and a submit button that names both
counts. Toast on success reports total assignments created, projects
touched, pairs skipped, and how many mentor emails went out.
This commit is contained in:
Matt
2026-05-26 14:25:41 +02:00
parent 195fc787a9
commit cb2a864b7f
2 changed files with 338 additions and 214 deletions

View File

@@ -43,7 +43,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [chosenMentorId, setChosenMentorId] = useState<string>('')
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
const [mentorSearch, setMentorSearch] = useState('')
const utils = trpc.useUtils()
@@ -63,17 +63,28 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
onSuccess: (result) => {
if (result.assignedCount === 0 && result.skippedCount > 0) {
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
toast.info(
`No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`,
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
`Assigned mentor to ${result.assignedCount} project${
result.assignedCount === 1 ? '' : 's'
}${result.skippedCount > 0 ? ` (${result.skippedCount} already had this mentor)` : ''}${
result.emailSent ? ' · email sent' : ''
`Created ${result.totalAssigned} assignment${
result.totalAssigned === 1 ? '' : 's'
} across ${result.touchedProjectCount} project${
result.touchedProjectCount === 1 ? '' : 's'
}${result.totalSkipped > 0 ? ` (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} already existed)` : ''}${
result.emailsSent > 0
? ` · ${result.emailsSent} mentor email${result.emailsSent === 1 ? '' : 's'} sent`
: ''
}`,
{
description:
mentorCount > 1
? `Each of ${mentorCount} mentors gets a single combined email listing only their new projects.`
: undefined,
},
)
}
utils.round.listMentoringProjects.invalidate({ roundId })
@@ -83,7 +94,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
utils.mentor.getRoundStats.invalidate({ roundId })
utils.project.list.invalidate()
setSelected(new Set())
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
setBulkOpen(false)
},
@@ -442,7 +453,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
onOpenChange={(next) => {
if (!next) {
setBulkOpen(false)
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
}
}}
@@ -450,117 +461,181 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<DialogContent>
<DialogHeader>
<DialogTitle>
Assign mentor to {selected.size} project
Assign mentors to {selected.size} project
{selected.size === 1 ? '' : 's'}
</DialogTitle>
<DialogDescription>
Choose one mentor they'll receive a single email listing every
new assignment. Projects where they're already an active mentor
will be skipped.
Tick any number of mentors. Each chosen mentor will be added to
every selected project they aren&apos;t already on. Each mentor
receives one combined email; each team receives one intro email
listing all of their mentors.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const mentors = mentorPool?.mentors ?? []
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? mentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: mentors
if (mentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
{(() => {
const allMentors = mentorPool?.mentors ?? []
const chosenMentors = allMentors.filter((m) =>
chosenMentorIds.has(m.id),
)
const upperBound = chosenMentorIds.size * selected.size
return (
<>
{chosenMentors.length > 0 && (
<div className="flex flex-wrap gap-1 rounded-md border bg-muted/30 p-2">
{chosenMentors.map((m) => (
<Badge
key={m.id}
variant="secondary"
className="gap-1 pl-2 pr-1"
>
{m.name ?? m.email}
<button
type="button"
aria-label={`Remove ${m.name ?? m.email}`}
className="rounded-full p-0.5 hover:bg-foreground/10"
onClick={() =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
next.delete(m.id)
return next
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={mentorSearch}
onChange={(e) => setMentorSearch(e.target.value)}
placeholder="Search mentor by name, email, country, or expertise…"
className="pl-8"
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border">
{(() => {
const q = mentorSearch.trim().toLowerCase()
const filteredMentors = q
? allMentors.filter((m) =>
[
m.name ?? '',
m.email,
m.country ?? '',
...(m.expertiseTags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q),
)
: allMentors
if (allMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors in the pool yet.{' '}
<Link
href="/admin/members?tab=mentors"
className="underline-offset-2 hover:underline"
>
Add mentors
</Link>
.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorIds.has(m.id)
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
className="mt-1"
checked={isChosen}
onCheckedChange={(checked) =>
setChosenMentorIds((prev) => {
const next = new Set(prev)
if (checked) next.add(m.id)
else next.delete(m.id)
return next
})
}
aria-label={`Toggle ${m.name ?? m.email}`}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge
variant="outline"
className="text-[10px]"
>
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
{chosenMentorIds.size > 0 && (
<p className="text-xs text-muted-foreground">
Will create up to{' '}
<span className="font-medium tabular-nums text-foreground">
{upperBound}
</span>{' '}
assignment{upperBound === 1 ? '' : 's'} (
{chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} × {selected.size}{' '}
project{selected.size === 1 ? '' : 's'}). Pairs that
already exist are skipped.
</p>
)
}
if (filteredMentors.length === 0) {
return (
<p className="p-4 text-center text-sm text-muted-foreground">
No mentors match &ldquo;{mentorSearch}&rdquo;.
</p>
)
}
return filteredMentors.map((m) => {
const isChosen = chosenMentorId === m.id
return (
<label
key={m.id}
className={`flex cursor-pointer items-start gap-3 border-b px-3 py-2 text-sm last:border-b-0 ${
isChosen ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<input
type="radio"
name="bulk-mentor"
className="mt-1"
checked={isChosen}
onChange={() => setChosenMentorId(m.id)}
/>
<div className="min-w-0 flex-1">
<div className="font-medium">
{m.name ?? 'Unnamed'}
</div>
<div className="truncate text-xs text-muted-foreground">
{m.email}
{m.country && <> · {m.country}</>}
</div>
{m.expertiseTags && m.expertiseTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{m.expertiseTags.slice(0, 4).map((t) => (
<Badge
key={t}
variant="secondary"
className="text-[10px]"
>
{t}
</Badge>
))}
{m.expertiseTags.length > 4 && (
<Badge variant="outline" className="text-[10px]">
+{m.expertiseTags.length - 4}
</Badge>
)}
</div>
)}
</div>
<div className="shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{m.currentAssignments}
{m.maxAssignments != null && `/${m.maxAssignments}`}{' '}
load
</div>
</label>
)
})
})()}
</div>
)}
</>
)
})()}
</div>
<DialogFooter>
@@ -568,7 +643,7 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
variant="outline"
onClick={() => {
setBulkOpen(false)
setChosenMentorId('')
setChosenMentorIds(new Set())
setMentorSearch('')
}}
>
@@ -577,16 +652,19 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<Button
onClick={() =>
bulkAssignMutation.mutate({
mentorId: chosenMentorId,
mentorIds: Array.from(chosenMentorIds),
projectIds: Array.from(selected),
})
}
disabled={!chosenMentorId || bulkAssignMutation.isPending}
disabled={
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
}
>
{bulkAssignMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Assign to {selected.size} project
Assign {chosenMentorIds.size} mentor
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
{selected.size === 1 ? '' : 's'}
</Button>
</DialogFooter>