feat(mentor): defer all assignment emails until round opens + per-project bulk UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m7s
Email policy - mentor.assign, mentor.bulkAssign, and autoAssignBulkForRound now suppress outbound email entirely when the project's MENTORING round is still ROUND_DRAFT. The MentorAssignment row is created (and in-app notifications still fire), but notificationSentAt and teamIntroducedAt remain null so activateRound can pick them up later. - activateRound, when activating a MENTORING round, now does a coalesced mentor-side email pass in addition to the existing team-side intro pass. Every (mentorId) bucket of pending assignments in this round gets exactly one combined email; the row stamps prevent duplicates on re-activation. - The "send immediately" path is preserved for assignments made while the round is already ROUND_ACTIVE — mentors and teams stay in the loop in real time, but staging during draft is silent. Per-project bulk UI - The /admin/projects/[id]/mentor manual picker now has a checkbox column, header select-all, and a primary-tinted action toolbar that appears when one or more candidates are selected. Submitting calls mentor.bulkAssign with the single projectId so the cartesian server path handles dedup, coalesced emails, and team intros uniformly with the round-page bulk.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -74,6 +75,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
assignmentId: string
|
||||
mentorName: string
|
||||
} | null>(null)
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id: projectId })
|
||||
|
||||
@@ -111,6 +115,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
utils.mentor.getMentorPool.invalidate()
|
||||
setPendingMentorId(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -119,6 +124,30 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.totalAssigned === 0) {
|
||||
toast.info('No new assignments — every chosen mentor was already on this team.')
|
||||
} else {
|
||||
toast.success(
|
||||
`Added ${result.totalAssigned} mentor${
|
||||
result.totalAssigned === 1 ? '' : 's'
|
||||
} to this team${
|
||||
result.emailsSent > 0
|
||||
? ` · ${result.emailsSent} email${result.emailsSent === 1 ? '' : 's'} sent`
|
||||
: ' · emails will go out when the mentoring round opens'
|
||||
}`,
|
||||
)
|
||||
}
|
||||
utils.project.get.invalidate({ id: projectId })
|
||||
utils.mentor.getCandidates.invalidate({ projectId })
|
||||
utils.mentor.getSuggestions.invalidate({ projectId })
|
||||
utils.mentor.getMentorPool.invalidate()
|
||||
setSelectedCandidateIds(new Set())
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const unassignMutation = trpc.mentor.unassign.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Mentor removed')
|
||||
@@ -383,6 +412,41 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{selectedCandidateIds.size > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{selectedCandidateIds.size}</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
mentor{selectedCandidateIds.size === 1 ? '' : 's'} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
bulkAssignMutation.mutate({
|
||||
mentorIds: Array.from(selectedCandidateIds),
|
||||
projectIds: [projectId],
|
||||
})
|
||||
}
|
||||
disabled={bulkAssignMutation.isPending}
|
||||
>
|
||||
{bulkAssignMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Add {selectedCandidateIds.size} mentor
|
||||
{selectedCandidateIds.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedCandidateIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{candidatesLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -400,6 +464,28 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredCandidates.length > 0 &&
|
||||
filteredCandidates.every((c) =>
|
||||
selectedCandidateIds.has(c.id),
|
||||
)
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedCandidateIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
filteredCandidates.forEach((c) => next.add(c.id))
|
||||
} else {
|
||||
filteredCandidates.forEach((c) => next.delete(c.id))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
aria-label="Select all visible mentors"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Mentor</TableHead>
|
||||
<TableHead>Expertise</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
@@ -410,7 +496,26 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCandidates.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableRow
|
||||
key={c.id}
|
||||
data-state={
|
||||
selectedCandidateIds.has(c.id) ? 'selected' : undefined
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedCandidateIds.has(c.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedCandidateIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(c.id)
|
||||
else next.delete(c.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
aria-label={`Select ${c.name ?? c.email}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{c.name ?? 'Unnamed'}</div>
|
||||
<div className="text-muted-foreground text-xs">{c.email}</div>
|
||||
|
||||
Reference in New Issue
Block a user