Platform polish: bulk invite, file requirements, filtering redesign, UX fixes

- F1: Set seed jury/mentors/observers to NONE status (not invited), remove passwords
- F2: Add bulk invite UI with checkbox selection and floating toolbar
- F3: Add getProjectRequirements backend query + requirement slots on project detail
- F4: Redesign filtering section: AI criteria textarea, "What AI sees" card,
  field-aware eligibility rules with human-readable previews
- F5: Auto-redirect to pipeline detail when only one pipeline exists
- F6: Make project names clickable in pipeline intake panel
- F7: Fix pipeline creation error: edition context fallback + .min(1) validation
- Pipeline wizard sections: add isActive locking, info tooltips, UX improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 23:45:21 +01:00
parent 451b483880
commit 70cfad7d46
28 changed files with 1312 additions and 200 deletions

View File

@@ -80,6 +80,7 @@ interface AssignmentConstraints {
requiredReviewsPerProject: number
minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number
jurorLimits?: Record<string, number> // userId -> personal max assignments
existingAssignments: Array<{
jurorId: string
projectId: string
@@ -260,9 +261,24 @@ function buildBatchPrompt(
}))
.filter((a) => a.jurorId && a.projectId)
// Build per-juror limits mapped to anonymous IDs
let jurorLimitsStr = ''
if (constraints.jurorLimits && Object.keys(constraints.jurorLimits).length > 0) {
const anonymousLimits: Record<string, number> = {}
for (const [realId, limit] of Object.entries(constraints.jurorLimits)) {
const anonId = jurorIdMap.get(realId)
if (anonId) {
anonymousLimits[anonId] = limit
}
}
if (Object.keys(anonymousLimits).length > 0) {
jurorLimitsStr = `\nJUROR_LIMITS: ${JSON.stringify(anonymousLimits)} (per-juror max assignments, override global max)`
}
}
return `JURORS: ${JSON.stringify(jurors)}
PROJECTS: ${JSON.stringify(projects)}
CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror
CONSTRAINTS: ${constraints.requiredReviewsPerProject} reviews/project, max ${constraints.maxAssignmentsPerJuror || 'unlimited'}/juror${jurorLimitsStr}
EXISTING: ${JSON.stringify(anonymousExisting)}
Return JSON: {"assignments": [...]}`
}

View File

@@ -419,12 +419,20 @@ export async function getSmartSuggestions(options: {
const suggestions: AssignmentScore[] = []
for (const user of users) {
// Skip users at AI max (they won't appear in suggestions)
const currentCount = user._count.assignments
// Skip users at AI max (they won't appear in suggestions)
if (currentCount >= aiMaxPerJudge) {
continue
}
// Per-juror hard block: skip entirely if at personal maxAssignments limit
if (user.maxAssignments !== null && user.maxAssignments !== undefined) {
if (currentCount >= user.maxAssignments) {
continue
}
}
for (const project of projects) {
// Skip if already assigned
const pairKey = `${user.id}:${project.id}`
@@ -621,6 +629,13 @@ export async function getMentorSuggestionsForProject(
continue
}
// Per-mentor hard block: skip entirely if at personal maxAssignments limit
if (mentor.maxAssignments !== null && mentor.maxAssignments !== undefined) {
if (mentor._count.mentorAssignments >= mentor.maxAssignments) {
continue
}
}
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
mentor.expertiseTags,
projectTags