fix(mentor): restore Add Project on mentoring rounds + gate mentor assignment
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s

Three related bugs around the mentoring-round Projects tab:

1. Add Project to Round was unreachable on MENTORING rounds — the table swap
   in the prior commit lost the button. Export AddProjectDialog from
   project-states-table and render it inside MentoringProjectsTable with an
   "Add" button in the filter row and a CTA in the empty state.
2. The "Assign Projects" quick action on the round overview linked to the
   global pool with an opaque filter; on MENTORING rounds it now switches
   to the Projects tab where the new Add Project button + auto-fill +
   per-team picker all live. Non-mentoring rounds keep the old behavior.
3. mentor.assign and mentor.bulkAssign now refuse projects that aren't
   enrolled in any MENTORING round (any status). The single-assign throws
   BAD_REQUEST with a guidance message; the bulk path filters them out and
   reports ineligibleProjectCount in the result so the UI can warn the
   admin instead of silently skipping.

Tests: the multi-mentor-assignment suite now sets up a MENTORING round +
ProjectRoundState for each project it tests against, matching the new gate.
This commit is contained in:
Matt
2026-05-26 15:20:01 +02:00
parent c4f7216bc1
commit 61dfc608cd
5 changed files with 183 additions and 27 deletions

View File

@@ -33,12 +33,32 @@ import {
Loader2,
Download,
X,
Plus,
} from 'lucide-react'
import { CountryDisplay } from '@/components/shared/country-display'
import { AddProjectDialog } from '@/components/admin/round/project-states-table'
type Filter = 'all' | 'unassigned' | 'assigned' | 'wants_only'
export function MentoringProjectsTable({ roundId }: { roundId: string }) {
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
export function MentoringProjectsTable({
roundId,
competitionId,
competitionRounds,
currentSortOrder,
}: {
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
}) {
const [addProjectOpen, setAddProjectOpen] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
@@ -67,6 +87,10 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
toast.info(
`No new assignments — every selected mentor is already on every selected project (${result.totalSkipped} pair${result.totalSkipped === 1 ? '' : 's'} skipped).`,
)
} else if (result.totalAssigned === 0 && result.ineligibleProjectCount > 0) {
toast.warning(
`${result.ineligibleProjectCount} project${result.ineligibleProjectCount === 1 ? '' : 's'} aren't in a mentoring round and were skipped.`,
)
} else {
const mentorCount = result.perMentor.filter((m) => m.assigned > 0).length
toast.success(
@@ -197,18 +221,32 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
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 className="flex items-center justify-end">
<Button size="sm" onClick={() => setAddProjectOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
Add Project to Round
</Button>
</div>
<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. Click{' '}
<span className="font-medium text-foreground">Add Project to Round</span>{' '}
above to populate it.
</div>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}
@@ -246,14 +284,24 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
<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 className="flex items-center gap-2">
<div className="relative w-full sm:w-72">
<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>
<Button
size="sm"
onClick={() => setAddProjectOpen(true)}
className="shrink-0"
>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
</div>
@@ -670,6 +718,21 @@ export function MentoringProjectsTable({ roundId }: { roundId: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.round.listMentoringProjects.invalidate({ roundId })
utils.round.getProjectsNeedingMentor.invalidate({ roundId })
utils.round.getMentoringImportCandidates.invalidate({ roundId })
utils.mentor.getRoundStats.invalidate({ roundId })
}}
/>
</div>
)
}

View File

@@ -785,7 +785,7 @@ function QuickAddDialog({
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
export function AddProjectDialog({
open,
onOpenChange,
roundId,