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
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:
@@ -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'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 “{mentorSearch}”.
|
||||
</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 “{mentorSearch}”.
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user