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.
676 lines
25 KiB
TypeScript
676 lines
25 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import Link from 'next/link'
|
||
import { toast } from 'sonner'
|
||
import { trpc } from '@/lib/trpc/client'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { Checkbox } from '@/components/ui/checkbox'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
Search,
|
||
UserPlus,
|
||
ArrowRight,
|
||
Sparkles,
|
||
Loader2,
|
||
Download,
|
||
X,
|
||
} from 'lucide-react'
|
||
import { CountryDisplay } from '@/components/shared/country-display'
|
||
|
||
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
|
||
|
||
export function MentoringProjectsTable({ roundId }: { roundId: string }) {
|
||
const [search, setSearch] = useState('')
|
||
const [filter, setFilter] = useState<Filter>('all')
|
||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||
const [bulkOpen, setBulkOpen] = useState(false)
|
||
const [chosenMentorIds, setChosenMentorIds] = useState<Set<string>>(new Set())
|
||
const [mentorSearch, setMentorSearch] = useState('')
|
||
|
||
const utils = trpc.useUtils()
|
||
|
||
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
|
||
{ roundId },
|
||
{ refetchInterval: 30_000 },
|
||
)
|
||
|
||
const { data: importCandidates } =
|
||
trpc.round.getMentoringImportCandidates.useQuery({ roundId })
|
||
|
||
const { data: mentorPool } = trpc.mentor.getMentorPool.useQuery(
|
||
{},
|
||
{ enabled: bulkOpen },
|
||
)
|
||
|
||
const bulkAssignMutation = trpc.mentor.bulkAssign.useMutation({
|
||
onSuccess: (result) => {
|
||
if (result.totalAssigned === 0 && result.totalSkipped > 0) {
|
||
toast.info(
|
||
`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(
|
||
`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 })
|
||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||
utils.mentor.getMentorPool.invalidate()
|
||
utils.mentor.getRoundStats.invalidate({ roundId })
|
||
utils.project.list.invalidate()
|
||
setSelected(new Set())
|
||
setChosenMentorIds(new Set())
|
||
setMentorSearch('')
|
||
setBulkOpen(false)
|
||
},
|
||
onError: (err) => toast.error(err.message),
|
||
})
|
||
|
||
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||
onSuccess: (result) => {
|
||
toast.success(
|
||
`Imported ${result.advancedCount} project${
|
||
result.advancedCount === 1 ? '' : 's'
|
||
} from ${result.targetRoundName ? '' : ''}${
|
||
importCandidates?.priorRound?.name ?? 'the prior round'
|
||
}`,
|
||
)
|
||
utils.round.listMentoringProjects.invalidate({ roundId })
|
||
utils.round.getMentoringImportCandidates.invalidate({ roundId })
|
||
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
|
||
},
|
||
onError: (err) => toast.error(err.message),
|
||
})
|
||
|
||
const importBanner = importCandidates?.priorRound &&
|
||
importCandidates.pendingCount > 0 && (
|
||
<div className="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="text-amber-900">
|
||
<span className="font-medium">
|
||
{importCandidates.pendingCount} PASSED project
|
||
{importCandidates.pendingCount === 1 ? '' : 's'}
|
||
</span>{' '}
|
||
from{' '}
|
||
<span className="font-medium">
|
||
{importCandidates.priorRound.name}
|
||
</span>{' '}
|
||
{importCandidates.pendingCount === 1 ? "isn't" : "aren't"} in this
|
||
mentoring round yet.
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() =>
|
||
advanceMutation.mutate({
|
||
roundId: importCandidates.priorRound!.id,
|
||
targetRoundId: roundId,
|
||
})
|
||
}
|
||
disabled={advanceMutation.isPending}
|
||
>
|
||
{advanceMutation.isPending ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Download className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
Import {importCandidates.pendingCount}
|
||
</Button>
|
||
</div>
|
||
)
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!data) return []
|
||
const q = search.trim().toLowerCase()
|
||
return data.projects.filter((p) => {
|
||
if (filter === 'unassigned' && p.mentors.length > 0) return false
|
||
if (filter === 'assigned' && p.mentors.length === 0) return false
|
||
if (filter === 'wants_only' && !p.wantsMentorship) return false
|
||
if (!q) return true
|
||
const hay = [
|
||
p.title,
|
||
p.teamName ?? '',
|
||
p.country ?? '',
|
||
...p.mentors.map((m) => m.name ?? m.email),
|
||
]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
return hay.includes(q)
|
||
})
|
||
}, [data, search, filter])
|
||
|
||
const totals = useMemo(() => {
|
||
if (!data)
|
||
return { total: 0, unassigned: 0, assigned: 0, wants: 0 }
|
||
return {
|
||
total: data.projects.length,
|
||
unassigned: data.projects.filter((p) => p.mentors.length === 0).length,
|
||
assigned: data.projects.filter((p) => p.mentors.length > 0).length,
|
||
wants: data.projects.filter((p) => p.wantsMentorship).length,
|
||
}
|
||
}, [data])
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{[1, 2, 3, 4, 5].map((i) => (
|
||
<Skeleton key={i} className="h-14 w-full" />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!data || data.projects.length === 0) {
|
||
return (
|
||
<div className="space-y-3">
|
||
{importBanner}
|
||
<div className="rounded-md border bg-muted/30 px-4 py-12 text-center text-sm text-muted-foreground">
|
||
No projects in this mentoring round yet.
|
||
{!importBanner && (
|
||
<>
|
||
{' '}Use{' '}
|
||
<span className="font-medium text-foreground">
|
||
Add Project to Round
|
||
</span>{' '}
|
||
to populate it.
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const Pill = ({
|
||
value,
|
||
label,
|
||
count,
|
||
}: {
|
||
value: Filter
|
||
label: string
|
||
count: number
|
||
}) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => setFilter(value)}
|
||
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||
filter === value
|
||
? 'border-primary bg-primary text-primary-foreground'
|
||
: 'bg-background hover:bg-muted'
|
||
}`}
|
||
>
|
||
{label}{' '}
|
||
<span className="tabular-nums opacity-80">({count})</span>
|
||
</button>
|
||
)
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{importBanner}
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<Pill value="all" label="All" count={totals.total} />
|
||
<Pill value="unassigned" label="No mentor" count={totals.unassigned} />
|
||
<Pill value="assigned" label="Has mentor" count={totals.assigned} />
|
||
<Pill value="wants_only" label="Wants mentorship" count={totals.wants} />
|
||
</div>
|
||
<div className="relative w-full sm:max-w-xs">
|
||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search projects, teams, or mentors…"
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{selected.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">{selected.size}</span>{' '}
|
||
<span className="text-muted-foreground">
|
||
project{selected.size === 1 ? '' : 's'} selected
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" onClick={() => setBulkOpen(true)}>
|
||
<UserPlus className="mr-1.5 h-4 w-4" />
|
||
Assign mentor…
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSelected(new Set())}
|
||
>
|
||
<X className="mr-1 h-4 w-4" />
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-between rounded-md border border-dashed bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
|
||
<span>
|
||
Tip: tick checkboxes to bulk-assign one mentor to multiple
|
||
projects in a single click (mentor gets one combined email).
|
||
</span>
|
||
{totals.unassigned > 0 && (
|
||
<button
|
||
type="button"
|
||
className="text-xs font-medium text-foreground hover:underline"
|
||
onClick={() => {
|
||
setFilter('unassigned')
|
||
setSelected(
|
||
new Set(
|
||
data.projects
|
||
.filter((p) => p.mentors.length === 0)
|
||
.map((p) => p.id),
|
||
),
|
||
)
|
||
}}
|
||
>
|
||
Select all {totals.unassigned} without a mentor
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-hidden rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-10">
|
||
<Checkbox
|
||
checked={
|
||
filtered.length > 0 &&
|
||
filtered.every((p) => selected.has(p.id))
|
||
}
|
||
onCheckedChange={(checked) => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev)
|
||
if (checked) {
|
||
filtered.forEach((p) => next.add(p.id))
|
||
} else {
|
||
filtered.forEach((p) => next.delete(p.id))
|
||
}
|
||
return next
|
||
})
|
||
}}
|
||
aria-label="Select all visible"
|
||
/>
|
||
</TableHead>
|
||
<TableHead>Project</TableHead>
|
||
<TableHead>Wants?</TableHead>
|
||
<TableHead>Mentors</TableHead>
|
||
<TableHead className="w-32 text-right">Action</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filtered.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={5}
|
||
className="py-8 text-center text-sm text-muted-foreground"
|
||
>
|
||
No projects match the current filter.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
filtered.map((p) => (
|
||
<TableRow
|
||
key={p.id}
|
||
data-state={selected.has(p.id) ? 'selected' : undefined}
|
||
>
|
||
<TableCell>
|
||
<Checkbox
|
||
checked={selected.has(p.id)}
|
||
onCheckedChange={(checked) =>
|
||
setSelected((prev) => {
|
||
const next = new Set(prev)
|
||
if (checked) next.add(p.id)
|
||
else next.delete(p.id)
|
||
return next
|
||
})
|
||
}
|
||
aria-label={`Select ${p.title}`}
|
||
/>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="font-medium">{p.title}</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
{p.teamName ?? '—'}
|
||
{p.country && (
|
||
<>
|
||
{' · '}
|
||
<CountryDisplay country={p.country} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex flex-col gap-1">
|
||
{p.wantsMentorship ? (
|
||
<Badge variant="secondary" className="w-fit text-xs">
|
||
Requested
|
||
</Badge>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">No</span>
|
||
)}
|
||
{p.finalistConfirmationStatus !== 'CONFIRMED' && (
|
||
<span
|
||
className="text-[10px] uppercase tracking-wide text-amber-700"
|
||
title="Auto-fill skips projects whose team has not confirmed attendance."
|
||
>
|
||
{p.finalistConfirmationStatus
|
||
? p.finalistConfirmationStatus.toLowerCase()
|
||
: 'no confirmation'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
{p.mentors.length === 0 ? (
|
||
<span className="text-xs italic text-muted-foreground">
|
||
Unassigned
|
||
</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{p.mentors.map((m) => (
|
||
<Badge
|
||
key={m.assignmentId}
|
||
variant="outline"
|
||
className="gap-1 text-xs"
|
||
title={m.email}
|
||
>
|
||
{(m.method === 'AI_AUTO' ||
|
||
m.method === 'AI_SUGGESTED') && (
|
||
<Sparkles className="h-3 w-3 text-amber-500" />
|
||
)}
|
||
{m.name ?? m.email}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button asChild size="sm" variant="outline">
|
||
<Link href={`/admin/projects/${p.id}/mentor`}>
|
||
{p.mentors.length === 0 ? (
|
||
<>
|
||
<UserPlus className="mr-1 h-3.5 w-3.5" />
|
||
Assign
|
||
</>
|
||
) : (
|
||
<>
|
||
Open
|
||
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||
</>
|
||
)}
|
||
</Link>
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<Dialog
|
||
open={bulkOpen}
|
||
onOpenChange={(next) => {
|
||
if (!next) {
|
||
setBulkOpen(false)
|
||
setChosenMentorIds(new Set())
|
||
setMentorSearch('')
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
Assign mentors to {selected.size} project
|
||
{selected.size === 1 ? '' : 's'}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
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">
|
||
{(() => {
|
||
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>
|
||
)}
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setBulkOpen(false)
|
||
setChosenMentorIds(new Set())
|
||
setMentorSearch('')
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={() =>
|
||
bulkAssignMutation.mutate({
|
||
mentorIds: Array.from(chosenMentorIds),
|
||
projectIds: Array.from(selected),
|
||
})
|
||
}
|
||
disabled={
|
||
chosenMentorIds.size === 0 || bulkAssignMutation.isPending
|
||
}
|
||
>
|
||
{bulkAssignMutation.isPending && (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
)}
|
||
Assign {chosenMentorIds.size} mentor
|
||
{chosenMentorIds.size === 1 ? '' : 's'} to {selected.size} project
|
||
{selected.size === 1 ? '' : 's'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|