2026-05-26 13:01:05 +02:00
|
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
import { useMemo, useState } from 'react'
|
|
|
|
|
import Link from 'next/link'
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
import { toast } from 'sonner'
|
2026-05-26 13:01:05 +02:00
|
|
|
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'
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
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'
|
2026-05-26 13:01:05 +02:00
|
|
|
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')
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
|
|
|
const [bulkOpen, setBulkOpen] = useState(false)
|
|
|
|
|
const [chosenMentorId, setChosenMentorId] = useState<string>('')
|
|
|
|
|
const [mentorSearch, setMentorSearch] = useState('')
|
|
|
|
|
|
|
|
|
|
const utils = trpc.useUtils()
|
2026-05-26 13:01:05 +02:00
|
|
|
|
|
|
|
|
const { data, isLoading } = trpc.round.listMentoringProjects.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
{ refetchInterval: 30_000 },
|
|
|
|
|
)
|
|
|
|
|
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
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.assignedCount === 0 && result.skippedCount > 0) {
|
|
|
|
|
toast.info(
|
|
|
|
|
`No new assignments — the selected mentor is already on all ${result.skippedCount} project${result.skippedCount === 1 ? '' : 's'}.`,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
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' : ''
|
|
|
|
|
}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
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())
|
|
|
|
|
setChosenMentorId('')
|
|
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-26 13:01:05 +02:00
|
|
|
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 (
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
<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>
|
2026-05-26 13:01:05 +02:00
|
|
|
</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">
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
{importBanner}
|
2026-05-26 13:01:05 +02:00
|
|
|
<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>
|
|
|
|
|
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-26 13:01:05 +02:00
|
|
|
<div className="overflow-hidden rounded-md border">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
<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>
|
2026-05-26 13:01:05 +02:00
|
|
|
<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
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
colSpan={5}
|
2026-05-26 13:01:05 +02:00
|
|
|
className="py-8 text-center text-sm text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
No projects match the current filter.
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
) : (
|
|
|
|
|
filtered.map((p) => (
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
<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>
|
2026-05-26 13:01:05 +02:00
|
|
|
<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>
|
feat(mentor): bulk assignment + coalesced emails + team intros on round open
Round-page bulk-assign UI
- Checkboxes on every project row, header select-all, primary-tinted action
toolbar that appears when 1+ rows are selected with an "Assign mentor…"
CTA and Clear. Dialog lists the mentor pool with search (name/email/
country/expertise), load indicator, and a radio picker.
- Always-visible tip strip when nothing is selected explains the bulk flow
and offers a one-click "Select all N without a mentor" shortcut.
- New tRPC procedure `mentor.bulkAssign({ mentorId, projectIds })` assigns
one mentor to many projects in a transaction; idempotent on the per-pair
`(projectId, mentorId)` unique; per-project in-app notifications still
fire for each team.
- Mutation invalidates listMentoringProjects, getProjectsNeedingMentor,
getMentoringImportCandidates, getMentorPool, getRoundStats, project.list
so the page reflects the new state without a refresh.
Coalesced mentor emails
- New `sendMentorBulkAssignmentEmail` (single email listing every newly-
assigned project + workspace links) used by `mentor.bulkAssign` and
`mentor.autoAssignBulkForRound`. The previously-silent auto-fill flow
now emails mentors at the end of the batch, one combined email per
mentor regardless of how many projects they received.
Team introduction emails when the round opens
- New `sendTeamMentorIntroductionEmail` lists every assigned mentor with
name + email and a link to the workspace, so teams can reach out
directly.
- `activateRound` (round-engine) fires the introduction for every project
in a MENTORING round that has active mentors when the round opens.
- `mentor.assign`, `mentor.bulkAssign`, and `autoAssignBulkForRound` also
fire the introduction immediately when the project's MENTORING round is
already ROUND_ACTIVE — so mentors added mid-round still reach the team.
- Idempotency via the new `MentorAssignment.teamIntroducedAt` column
(migration 20260526114936) — independent from `notificationSentAt` so
pre-existing mentor-side stamps don't suppress the team-side email.
2026-05-26 14:04:32 +02:00
|
|
|
|
|
|
|
|
<Dialog
|
|
|
|
|
open={bulkOpen}
|
|
|
|
|
onOpenChange={(next) => {
|
|
|
|
|
if (!next) {
|
|
|
|
|
setBulkOpen(false)
|
|
|
|
|
setChosenMentorId('')
|
|
|
|
|
setMentorSearch('')
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
Assign mentor 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.
|
|
|
|
|
</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>
|
|
|
|
|
.
|
|
|
|
|
</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>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setBulkOpen(false)
|
|
|
|
|
setChosenMentorId('')
|
|
|
|
|
setMentorSearch('')
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() =>
|
|
|
|
|
bulkAssignMutation.mutate({
|
|
|
|
|
mentorId: chosenMentorId,
|
|
|
|
|
projectIds: Array.from(selected),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
disabled={!chosenMentorId || bulkAssignMutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
{bulkAssignMutation.isPending && (
|
|
|
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
|
|
|
)}
|
|
|
|
|
Assign to {selected.size} project
|
|
|
|
|
{selected.size === 1 ? '' : 's'}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2026-05-26 13:01:05 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|