Files
MOPC-Portal/src/components/admin/round/mentoring-projects-table.tsx
Matt cb2a864b7f
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
feat(mentor): many-to-many bulk assignment (multi-mentor × multi-project)
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.
2026-05-26 14:25:41 +02:00

676 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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&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">
{(() => {
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>
)}
</>
)
})()}
</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>
)
}