Platform polish: bulk invite, file requirements, filtering redesign, UX fixes
Build and Push Docker Image / build (push) Successful in 11m41s Details

- 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:
Matt 2026-02-13 23:45:21 +01:00
parent 451b483880
commit 70cfad7d46
28 changed files with 1312 additions and 200 deletions

View File

@ -186,7 +186,6 @@ async function main() {
const juryUserIds: string[] = [] const juryUserIds: string[] = []
for (const j of juryMembers) { for (const j of juryMembers) {
const passwordHash = await bcrypt.hash('Jury2026!', 12)
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email: j.email }, where: { email: j.email },
update: {}, update: {},
@ -194,11 +193,9 @@ async function main() {
email: j.email, email: j.email,
name: j.name, name: j.name,
role: UserRole.JURY_MEMBER, role: UserRole.JURY_MEMBER,
status: UserStatus.ACTIVE, status: UserStatus.NONE,
country: j.country, country: j.country,
expertiseTags: j.tags, expertiseTags: j.tags,
passwordHash,
mustSetPassword: true,
bio: `Expert in ${j.tags.join(', ')}`, bio: `Expert in ${j.tags.join(', ')}`,
}, },
}) })
@ -218,7 +215,6 @@ async function main() {
] ]
for (const m of mentors) { for (const m of mentors) {
const passwordHash = await bcrypt.hash('Mentor2026!', 12)
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: m.email }, where: { email: m.email },
update: {}, update: {},
@ -226,11 +222,9 @@ async function main() {
email: m.email, email: m.email,
name: m.name, name: m.name,
role: UserRole.MENTOR, role: UserRole.MENTOR,
status: UserStatus.ACTIVE, status: UserStatus.NONE,
country: m.country, country: m.country,
expertiseTags: m.tags, expertiseTags: m.tags,
passwordHash,
mustSetPassword: true,
}, },
}) })
console.log(` ✓ Mentor: ${m.name}`) console.log(` ✓ Mentor: ${m.name}`)
@ -247,7 +241,6 @@ async function main() {
] ]
for (const o of observers) { for (const o of observers) {
const passwordHash = await bcrypt.hash('Observer2026!', 12)
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: o.email }, where: { email: o.email },
update: {}, update: {},
@ -255,10 +248,8 @@ async function main() {
email: o.email, email: o.email,
name: o.name, name: o.name,
role: UserRole.OBSERVER, role: UserRole.OBSERVER,
status: UserStatus.ACTIVE, status: UserStatus.NONE,
country: o.country, country: o.country,
passwordHash,
mustSetPassword: true,
}, },
}) })
console.log(` ✓ Observer: ${o.name}`) console.log(` ✓ Observer: ${o.name}`)

View File

@ -38,6 +38,7 @@ import {
Calendar, Calendar,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Circle,
Clock, Clock,
BarChart3, BarChart3,
ThumbsUp, ThumbsUp,
@ -86,6 +87,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility) // Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId }) const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch file requirements from the pipeline's intake stage
const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
{ projectId },
{ enabled: !!project }
)
// Fetch available stages for upload selector (if project has a programId) // Fetch available stages for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery( const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' }, { id: project?.programId || '' },
@ -521,7 +528,90 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{files && files.length > 0 ? ( {/* Required Documents from Pipeline Intake Stage */}
{requirementsData && requirementsData.requirements.length > 0 && (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{requirementsData.requirements.map((req, idx) => {
const isFulfilled = req.fulfilled
return (
<div
key={req.id ?? `req-${idx}`}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="secondary" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{req.description && (
<span className="truncate">{req.description}</span>
)}
{req.maxSizeMB && (
<span className="shrink-0">Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && req.fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{req.fulfilledFile.fileName}
</p>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Missing
</span>
)}
</div>
)
})}
</div>
</div>
<Separator />
</>
)}
{/* Additional Documents Upload */}
<div>
<p className="text-sm font-semibold mb-3">
{requirementsData && requirementsData.requirements.length > 0
? 'Additional Documents'
: 'Upload New Files'}
</p>
<FileUpload
projectId={projectId}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</div>
{/* All Files list */}
{files && files.length > 0 && (
<>
<Separator />
<div>
<p className="text-sm font-semibold mb-3">All Files</p>
<FileViewer <FileViewer
projectId={projectId} projectId={projectId}
files={files.map((f) => ({ files={files.map((f) => ({
@ -534,22 +624,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
objectKey: f.objectKey, objectKey: f.objectKey,
}))} }))}
/> />
) : (
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
)}
<Separator className="my-4" />
<div>
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
}}
/>
</div> </div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>

View File

@ -20,6 +20,7 @@ import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-fin
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section' import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section' import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
import { useEdition } from '@/contexts/edition-context'
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults' import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation' import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard' import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
@ -27,12 +28,20 @@ import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFin
export default function NewPipelinePage() { export default function NewPipelinePage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const programId = searchParams.get('programId') ?? '' const { currentEdition } = useEdition()
const programId = searchParams.get('programId') || currentEdition?.id || ''
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId)) const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
const [openSection, setOpenSection] = useState(0) const [openSection, setOpenSection] = useState(0)
const initialStateRef = useRef(JSON.stringify(state)) const initialStateRef = useRef(JSON.stringify(state))
// Update programId in state when edition context loads
useEffect(() => {
if (programId && !state.programId) {
setState((prev) => ({ ...prev, programId }))
}
}, [programId, state.programId])
// Dirty tracking — warn on navigate away // Dirty tracking — warn on navigate away
useEffect(() => { useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => { const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@ -161,6 +170,26 @@ export default function NewPipelinePage() {
const isSaving = createMutation.isPending || publishMutation.isPending const isSaving = createMutation.isPending || publishMutation.isPending
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href="/admin/rounds/pipelines">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Create Pipeline</h1>
<p className="text-sm text-muted-foreground">
Please select an edition first to create a pipeline.
</p>
</div>
</div>
</div>
)
}
const sections = [ const sections = [
{ {
title: 'Basics', title: 'Basics',

View File

@ -1,5 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -40,6 +42,7 @@ const statusColors: Record<string, string> = {
} }
export default function PipelineListPage() { export default function PipelineListPage() {
const router = useRouter()
const { currentEdition } = useEdition() const { currentEdition } = useEdition()
const programId = currentEdition?.id const programId = currentEdition?.id
@ -48,6 +51,13 @@ export default function PipelineListPage() {
{ enabled: !!programId } { enabled: !!programId }
) )
// Auto-redirect when there's exactly one pipeline
useEffect(() => {
if (!isLoading && pipelines && pipelines.length === 1) {
router.replace(`/admin/rounds/pipeline/${pipelines[0].id}` as Route)
}
}, [isLoading, pipelines, router])
if (!programId) { if (!programId) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -1,12 +1,13 @@
'use client' 'use client'
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation' import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Card, Card,
CardContent, CardContent,
@ -27,9 +28,10 @@ import { Skeleton } from '@/components/ui/skeleton'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions' import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
import { Pagination } from '@/components/shared/pagination' import { Pagination } from '@/components/shared/pagination'
import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react' import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatRelativeTime } from '@/lib/utils' import { formatRelativeTime } from '@/lib/utils'
import { AnimatePresence, motion } from 'motion/react'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
@ -131,6 +133,8 @@ export function MembersContent() {
const roles = TAB_ROLES[tab] const roles = TAB_ROLES[tab]
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const { data: currentUser } = trpc.user.me.useQuery() const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined const currentUserRole = currentUser?.role as RoleValue | undefined
@ -141,6 +145,75 @@ export function MembersContent() {
perPage: 20, perPage: 20,
}) })
const utils = trpc.useUtils()
const bulkInvite = trpc.user.bulkSendInvitations.useMutation({
onSuccess: (result) => {
const { sent, errors } = result as { sent: number; skipped: number; errors: string[] }
if (errors && errors.length > 0) {
toast.warning(`Sent ${sent} invitation${sent !== 1 ? 's' : ''}, ${errors.length} failed`)
} else {
toast.success(`Invitations sent to ${sent} member${sent !== 1 ? 's' : ''}`)
}
setSelectedIds(new Set())
utils.user.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to send invitations')
},
})
// Users on the current page that are selectable (status NONE)
const selectableUsers = useMemo(
() => (data?.users ?? []).filter((u) => u.status === 'NONE'),
[data?.users]
)
const allSelectableSelected =
selectableUsers.length > 0 && selectableUsers.every((u) => selectedIds.has(u.id))
const someSelectableSelected =
selectableUsers.some((u) => selectedIds.has(u.id)) && !allSelectableSelected
const toggleUser = useCallback((userId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(userId)) {
next.delete(userId)
} else {
next.add(userId)
}
return next
})
}, [])
const toggleAll = useCallback(() => {
if (allSelectableSelected) {
// Deselect all on this page
setSelectedIds((prev) => {
const next = new Set(prev)
for (const u of selectableUsers) {
next.delete(u.id)
}
return next
})
} else {
// Select all selectable on this page
setSelectedIds((prev) => {
const next = new Set(prev)
for (const u of selectableUsers) {
next.add(u.id)
}
return next
})
}
}, [allSelectableSelected, selectableUsers])
// Clear selection when filters/page change
useEffect(() => {
setSelectedIds(new Set())
}, [tab, search, page])
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
updateParams({ tab: value === 'all' ? null : value, page: '1' }) updateParams({ tab: value === 'all' ? null : value, page: '1' })
} }
@ -197,6 +270,15 @@ export function MembersContent() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-10">
{selectableUsers.length > 0 && (
<Checkbox
checked={allSelectableSelected ? true : someSelectableSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label="Select all uninvited members"
/>
)}
</TableHead>
<TableHead>Member</TableHead> <TableHead>Member</TableHead>
<TableHead>Role</TableHead> <TableHead>Role</TableHead>
<TableHead>Expertise</TableHead> <TableHead>Expertise</TableHead>
@ -209,6 +291,17 @@ export function MembersContent() {
<TableBody> <TableBody>
{data.users.map((user) => ( {data.users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell>
{user.status === 'NONE' ? (
<Checkbox
checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)}
aria-label={`Select ${user.name || user.email}`}
/>
) : (
<span />
)}
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserAvatar <UserAvatar
@ -297,6 +390,14 @@ export function MembersContent() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.status === 'NONE' && (
<Checkbox
checked={selectedIds.has(user.id)}
onCheckedChange={() => toggleUser(user.id)}
aria-label={`Select ${user.name || user.email}`}
className="mt-1"
/>
)}
<UserAvatar <UserAvatar
user={user} user={user}
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined} avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
@ -395,6 +496,50 @@ export function MembersContent() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Floating bulk invite toolbar */}
<AnimatePresence>
{selectedIds.size > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50"
>
<Card className="shadow-lg border-2">
<CardContent className="flex items-center gap-3 px-4 py-3">
<span className="text-sm font-medium whitespace-nowrap">
{selectedIds.size} selected
</span>
<Button
size="sm"
onClick={() => bulkInvite.mutate({ userIds: Array.from(selectedIds) })}
disabled={bulkInvite.isPending}
className="gap-1.5"
>
{bulkInvite.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
Invite Selected
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
disabled={bulkInvite.isPending}
className="gap-1.5"
>
<X className="h-4 w-4" />
Clear
</Button>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div> </div>
) )
} }

View File

@ -10,6 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { EvaluationConfig } from '@/types/pipeline-wizard' import type { EvaluationConfig } from '@/types/pipeline-wizard'
type AssignmentSectionProps = { type AssignmentSectionProps = {
@ -27,7 +28,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Required Reviews per Project</Label> <Label>Required Reviews per Project</Label>
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
</div>
<Input <Input
type="number" type="number"
min={1} min={1}
@ -44,7 +48,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Max Load per Juror</Label> <Label>Max Load per Juror</Label>
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
</div>
<Input <Input
type="number" type="number"
min={1} min={1}
@ -61,7 +68,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Min Load per Juror</Label> <Label>Min Load per Juror</Label>
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
</div>
<Input <Input
type="number" type="number"
min={0} min={0}
@ -86,7 +96,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Label>Availability Weighting</Label> <Label>Availability Weighting</Label>
<InfoTooltip content="When enabled, jurors who are available during the voting window are prioritized in assignment." />
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Factor in juror availability when assigning projects Factor in juror availability when assigning projects
</p> </p>
@ -101,7 +114,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Overflow Policy</Label> <Label>Overflow Policy</Label>
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
</div>
<Select <Select
value={config.overflowPolicy ?? 'queue'} value={config.overflowPolicy ?? 'queue'}
onValueChange={(value) => onValueChange={(value) =>

View File

@ -24,6 +24,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Plus, Trash2, Trophy } from 'lucide-react' import { Plus, Trash2, Trophy } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { defaultAwardTrack } from '@/lib/pipeline-defaults' import { defaultAwardTrack } from '@/lib/pipeline-defaults'
import type { WizardTrackConfig } from '@/types/pipeline-wizard' import type { WizardTrackConfig } from '@/types/pipeline-wizard'
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client' import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
@ -146,7 +147,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Routing Mode</Label> <Label className="text-xs">Routing Mode</Label>
<InfoTooltip content="Parallel: projects compete for all awards simultaneously. Exclusive: each project can only win one award. Post-main: awards are decided after the main track completes." />
</div>
<Select <Select
value={track.routingModeDefault ?? 'PARALLEL'} value={track.routingModeDefault ?? 'PARALLEL'}
onValueChange={(value) => onValueChange={(value) =>
@ -176,7 +180,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Decision Mode</Label> <Label className="text-xs">Decision Mode</Label>
<InfoTooltip content="How the winner is determined for this award track." />
</div>
<Select <Select
value={track.decisionMode ?? 'JURY_VOTE'} value={track.decisionMode ?? 'JURY_VOTE'}
onValueChange={(value) => onValueChange={(value) =>
@ -197,7 +204,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Scoring Mode</Label> <Label className="text-xs">Scoring Mode</Label>
<InfoTooltip content="The method used to aggregate scores for this award." />
</div>
<Select <Select
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'} value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
onValueChange={(value) => onValueChange={(value) =>

View File

@ -10,6 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import type { WizardState } from '@/types/pipeline-wizard' import type { WizardState } from '@/types/pipeline-wizard'
@ -52,7 +53,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-slug">Slug</Label> <Label htmlFor="pipeline-slug">Slug</Label>
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
</div>
<Input <Input
id="pipeline-slug" id="pipeline-slug"
placeholder="e.g., mopc-2026" placeholder="e.g., mopc-2026"
@ -70,7 +74,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="pipeline-program">Program</Label> <Label htmlFor="pipeline-program">Program</Label>
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
</div>
<Select <Select
value={state.programId} value={state.programId}
onValueChange={(value) => onChange({ programId: value })} onValueChange={(value) => onChange({ programId: value })}

View File

@ -1,11 +1,13 @@
'use client' 'use client'
import { Input } from '@/components/ui/input' import { useState } from 'react'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -13,21 +15,102 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Plus, Trash2 } from 'lucide-react' import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from '@/components/ui/collapsible'
import { Plus, Trash2, ChevronDown, Info, Brain, Shield } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard' import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
// ─── Known Fields for Eligibility Rules ──────────────────────────────────────
type KnownField = {
value: string
label: string
operators: string[]
valueType: 'select' | 'text' | 'number' | 'boolean'
placeholder?: string
}
const KNOWN_FIELDS: KnownField[] = [
{ value: 'competitionCategory', label: 'Category', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. STARTUP' },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Pollution' },
{ value: 'country', label: 'Country', operators: ['is', 'is_not', 'is_one_of'], valueType: 'text', placeholder: 'e.g. France' },
{ value: 'geographicZone', label: 'Region', operators: ['is', 'is_not'], valueType: 'text', placeholder: 'e.g. Mediterranean' },
{ value: 'foundedAt', label: 'Founded Year', operators: ['after', 'before'], valueType: 'number', placeholder: 'e.g. 2020' },
{ value: 'description', label: 'Has Description', operators: ['exists', 'min_length'], valueType: 'number', placeholder: 'Min chars' },
{ value: 'files', label: 'File Count', operators: ['greaterThan', 'lessThan'], valueType: 'number', placeholder: 'e.g. 1' },
{ value: 'wantsMentorship', label: 'Wants Mentorship', operators: ['equals'], valueType: 'boolean' },
]
const OPERATOR_LABELS: Record<string, string> = {
is: 'is',
is_not: 'is not',
is_one_of: 'is one of',
after: 'after',
before: 'before',
exists: 'exists',
min_length: 'min length',
greaterThan: 'greater than',
lessThan: 'less than',
equals: 'equals',
}
// ─── Human-readable preview for a rule ───────────────────────────────────────
function getRulePreview(rule: FilterRuleConfig): string {
const field = KNOWN_FIELDS.find((f) => f.value === rule.field)
const fieldLabel = field?.label ?? rule.field
const opLabel = OPERATOR_LABELS[rule.operator] ?? rule.operator
if (rule.operator === 'exists') {
return `Projects where ${fieldLabel} exists will pass`
}
const valueStr = typeof rule.value === 'boolean'
? (rule.value ? 'Yes' : 'No')
: String(rule.value)
return `Projects where ${fieldLabel} ${opLabel} ${valueStr} will pass`
}
// ─── AI Screening: Fields the AI Sees ────────────────────────────────────────
const AI_VISIBLE_FIELDS = [
'Project title',
'Description',
'Competition category',
'Ocean issue',
'Country & region',
'Tags',
'Founded year',
'Team size',
'File count',
]
// ─── Props ───────────────────────────────────────────────────────────────────
type FilteringSectionProps = { type FilteringSectionProps = {
config: FilterConfig config: FilterConfig
onChange: (config: FilterConfig) => void onChange: (config: FilterConfig) => void
isActive?: boolean isActive?: boolean
} }
// ─── Component ───────────────────────────────────────────────────────────────
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) { export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
const [rulesOpen, setRulesOpen] = useState(false)
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
const updateConfig = (updates: Partial<FilterConfig>) => { const updateConfig = (updates: Partial<FilterConfig>) => {
onChange({ ...config, ...updates }) onChange({ ...config, ...updates })
} }
const rules = config.rules ?? [] const rules = config.rules ?? []
const aiCriteriaText = config.aiCriteriaText ?? ''
const thresholds = config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => { const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
const updated = [...rules] const updated = [...rules]
@ -40,7 +123,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
...config, ...config,
rules: [ rules: [
...rules, ...rules,
{ field: '', operator: 'equals', value: '', weight: 1 }, { field: '', operator: 'is', value: '', weight: 1 },
], ],
}) })
} }
@ -49,89 +132,26 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
onChange({ ...config, rules: rules.filter((_, i) => i !== index) }) onChange({ ...config, rules: rules.filter((_, i) => i !== index) })
} }
const getFieldConfig = (fieldValue: string): KnownField | undefined => {
return KNOWN_FIELDS.find((f) => f.value === fieldValue)
}
const highPct = Math.round(thresholds.high * 100)
const medPct = Math.round(thresholds.medium * 100)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Deterministic Gate Rules */} {/* ── AI Screening (Primary) ────────────────────────────────────── */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Gate Rules</Label>
<p className="text-xs text-muted-foreground">
Deterministic rules that projects must pass. Applied in order.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
{rules.map((rule, index) => (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
<Input
placeholder="Field name"
value={rule.field}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { field: e.target.value })}
/>
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="notEquals">Not Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="greaterThan">Greater Than</SelectItem>
<SelectItem value="lessThan">Less Than</SelectItem>
<SelectItem value="exists">Exists</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Value"
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No gate rules configured. All projects will pass through.
</p>
)}
</div>
{/* AI Rubric */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label> <Label>AI Screening</Label>
<p className="text-xs text-muted-foreground"> <InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
Use AI to evaluate projects against rubric criteria </div>
<p className="text-xs text-muted-foreground mt-0.5">
Use AI to evaluate projects against your screening criteria
</p> </p>
</div> </div>
<Switch <Switch
@ -143,15 +163,97 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
{config.aiRubricEnabled && ( {config.aiRubricEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted"> <div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">High Confidence Threshold</Label> <Label className="text-sm font-medium">Screening Criteria</Label>
<div className="flex items-center gap-3"> <p className="text-xs text-muted-foreground">
Describe what makes a project eligible or ineligible in natural language.
The AI will evaluate each project against these criteria.
</p>
<Textarea
value={aiCriteriaText}
onChange={(e) => updateConfig({ aiCriteriaText: e.target.value })}
placeholder="e.g., Projects must demonstrate a clear ocean conservation impact. Reject projects that are purely commercial with no environmental benefit. Flag projects with vague descriptions for manual review."
rows={5}
className="resize-y"
disabled={isActive}
/>
{aiCriteriaText.length > 0 && (
<p className="text-xs text-muted-foreground text-right">
{aiCriteriaText.length} characters
</p>
)}
</div>
{/* "What the AI sees" Info Card */}
<Collapsible open={aiFieldsOpen} onOpenChange={setAiFieldsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
<Info className="h-3.5 w-3.5" />
<span>What the AI sees</span>
<ChevronDown className={`h-3.5 w-3.5 ml-auto transition-transform ${aiFieldsOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<Card className="mt-2 bg-muted/50 border-muted">
<CardContent className="pt-3 pb-3 px-4">
<p className="text-xs text-muted-foreground mb-2">
All data is anonymized before being sent to the AI. Only these fields are included:
</p>
<ul className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{AI_VISIBLE_FIELDS.map((field) => (
<li key={field} className="text-xs text-muted-foreground flex items-center gap-1">
<span className="h-1 w-1 rounded-full bg-muted-foreground/50 shrink-0" />
{field}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground/70 mt-2 italic">
No personal identifiers (names, emails, etc.) are sent to the AI.
</p>
</CardContent>
</Card>
</CollapsibleContent>
</Collapsible>
{/* Confidence Thresholds */}
<div className="space-y-3">
<Label className="text-sm font-medium">Confidence Thresholds</Label>
<p className="text-xs text-muted-foreground">
Control how the AI's confidence score maps to outcomes.
</p>
{/* Visual range preview */}
<div className="flex items-center gap-1 text-[10px] font-medium">
<div className="flex-1 bg-emerald-100 dark:bg-emerald-950 border border-emerald-300 dark:border-emerald-800 rounded-l px-2 py-1 text-center text-emerald-700 dark:text-emerald-400">
Auto-approve above {highPct}%
</div>
<div className="flex-1 bg-amber-100 dark:bg-amber-950 border border-amber-300 dark:border-amber-800 px-2 py-1 text-center text-amber-700 dark:text-amber-400">
Review {medPct}%{'\u2013'}{highPct}%
</div>
<div className="flex-1 bg-red-100 dark:bg-red-950 border border-red-300 dark:border-red-800 rounded-r px-2 py-1 text-center text-red-700 dark:text-red-400">
Auto-reject below {medPct}%
</div>
</div>
{/* High threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500 shrink-0" />
<Label className="text-xs">Auto-approve threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{highPct}%</span>
</div>
<Slider <Slider
value={[(config.aiConfidenceThresholds?.high ?? 0.85) * 100]} value={[highPct]}
onValueChange={([v]) => onValueChange={([v]) =>
updateConfig({ updateConfig({
aiConfidenceThresholds: { aiConfidenceThresholds: {
...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }), ...thresholds,
high: v / 100, high: v / 100,
}, },
}) })
@ -159,22 +261,25 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
min={50} min={50}
max={100} max={100}
step={5} step={5}
className="flex-1" disabled={isActive}
/> />
<span className="text-xs font-mono w-10 text-right">
{Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%
</span>
</div> </div>
{/* Medium threshold slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500 shrink-0" />
<Label className="text-xs">Manual review threshold</Label>
</div>
<span className="text-xs font-mono font-medium">{medPct}%</span>
</div> </div>
<div className="space-y-2">
<Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3">
<Slider <Slider
value={[(config.aiConfidenceThresholds?.medium ?? 0.6) * 100]} value={[medPct]}
onValueChange={([v]) => onValueChange={([v]) =>
updateConfig({ updateConfig({
aiConfidenceThresholds: { aiConfidenceThresholds: {
...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }), ...thresholds,
medium: v / 100, medium: v / 100,
}, },
}) })
@ -182,21 +287,21 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
min={20} min={20}
max={80} max={80}
step={5} step={5}
className="flex-1" disabled={isActive}
/> />
<span className="text-xs font-mono w-10 text-right">
{Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%
</span>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Manual Queue */} {/* ── Manual Review Queue ────────────────────────────────────────── */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Label>Manual Review Queue</Label> <Label>Manual Review Queue</Label>
<InfoTooltip content="When enabled, projects that don't meet auto-processing thresholds are queued for admin review instead of being auto-rejected." />
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Projects below medium confidence go to manual review Projects below medium confidence go to manual review
</p> </p>
@ -207,6 +312,168 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
disabled={isActive} disabled={isActive}
/> />
</div> </div>
{/* ── Eligibility Rules (Secondary, Collapsible) ─────────────────── */}
<Collapsible open={rulesOpen} onOpenChange={setRulesOpen}>
<div className="flex items-center justify-between">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Shield className="h-4 w-4 text-muted-foreground" />
<Label className="cursor-pointer">Eligibility Rules</Label>
<span className="text-xs text-muted-foreground">
({rules.length} rule{rules.length !== 1 ? 's' : ''})
</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${rulesOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
{rulesOpen && (
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
Deterministic rules that projects must pass. Applied before AI screening.
</p>
<CollapsibleContent>
<div className="space-y-3 mt-3">
{rules.map((rule, index) => {
const fieldConfig = getFieldConfig(rule.field)
const availableOperators = fieldConfig?.operators ?? Object.keys(OPERATOR_LABELS)
return (
<Card key={index}>
<CardContent className="pt-3 pb-3 px-4 space-y-2">
{/* Rule inputs */}
<div className="flex items-start gap-2">
<div className="flex-1 grid gap-2 sm:grid-cols-3">
{/* Field dropdown */}
<Select
value={rule.field}
onValueChange={(value) => {
const newFieldConfig = getFieldConfig(value)
const firstOp = newFieldConfig?.operators[0] ?? 'is'
updateRule(index, {
field: value,
operator: firstOp,
value: newFieldConfig?.valueType === 'boolean' ? true : '',
})
}}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent>
{KNOWN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Operator dropdown (filtered by field) */}
<Select
value={rule.operator}
onValueChange={(value) => updateRule(index, { operator: value })}
disabled={isActive || !rule.field}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableOperators.map((op) => (
<SelectItem key={op} value={op}>
{OPERATOR_LABELS[op] ?? op}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value input (adapted by field type) */}
{rule.operator === 'exists' ? (
<div className="h-8 flex items-center text-xs text-muted-foreground italic">
(no value needed)
</div>
) : fieldConfig?.valueType === 'boolean' ? (
<Select
value={String(rule.value)}
onValueChange={(v) => updateRule(index, { value: v === 'true' })}
disabled={isActive}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
) : fieldConfig?.valueType === 'number' ? (
<Input
type="number"
placeholder={fieldConfig.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value ? Number(e.target.value) : '' })}
/>
) : (
<Input
placeholder={fieldConfig?.placeholder ?? 'Value'}
value={String(rule.value)}
className="h-8 text-sm"
disabled={isActive}
onChange={(e) => updateRule(index, { value: e.target.value })}
/>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive mt-0.5"
onClick={() => removeRule(index)}
disabled={isActive}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Human-readable preview */}
{rule.field && rule.operator && (
<p className="text-xs text-muted-foreground italic pl-1">
{getRulePreview(rule)}
</p>
)}
</CardContent>
</Card>
)
})}
{rules.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-3">
No eligibility rules configured. All projects will pass through to AI screening (if enabled).
</p>
)}
{!rulesOpen ? null : rules.length > 0 && (
<div className="flex justify-end">
<Button type="button" variant="outline" size="sm" onClick={addRule} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div> </div>
) )
} }

View File

@ -12,8 +12,69 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Plus, Trash2, FileText } from 'lucide-react' import { Plus, Trash2, FileText } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard' import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
import {
FILE_TYPE_CATEGORIES,
getActiveCategoriesFromMimeTypes,
categoriesToMimeTypes,
} from '@/lib/file-type-categories'
type FileTypePickerProps = {
value: string[]
onChange: (mimeTypes: string[]) => void
}
function FileTypePicker({ value, onChange }: FileTypePickerProps) {
const activeCategories = getActiveCategoriesFromMimeTypes(value)
const toggleCategory = (categoryId: string) => {
const isActive = activeCategories.includes(categoryId)
const newCategories = isActive
? activeCategories.filter((id) => id !== categoryId)
: [...activeCategories, categoryId]
onChange(categoriesToMimeTypes(newCategories))
}
return (
<div className="space-y-2">
<Label className="text-xs">Accepted Types</Label>
<div className="flex flex-wrap gap-1.5">
{FILE_TYPE_CATEGORIES.map((cat) => {
const isActive = activeCategories.includes(cat.id)
return (
<Button
key={cat.id}
type="button"
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs px-2.5"
onClick={() => toggleCategory(cat.id)}
>
{cat.label}
</Button>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{activeCategories.length === 0 ? (
<Badge variant="secondary" className="text-[10px]">All types</Badge>
) : (
activeCategories.map((catId) => {
const cat = FILE_TYPE_CATEGORIES.find((c) => c.id === catId)
return cat ? (
<Badge key={catId} variant="secondary" className="text-[10px]">
{cat.label}
</Badge>
) : null
})
)}
</div>
</div>
)
}
type IntakeSectionProps = { type IntakeSectionProps = {
config: IntakeConfig config: IntakeConfig
@ -67,7 +128,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Label>Submission Window</Label> <Label>Submission Window</Label>
<InfoTooltip content="When enabled, projects can only be submitted within the configured date range." />
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable timed submission windows for project intake Enable timed submission windows for project intake
</p> </p>
@ -85,7 +149,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
{/* Late Policy */} {/* Late Policy */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Late Submission Policy</Label> <Label>Late Submission Policy</Label>
<InfoTooltip content="Controls how submissions after the deadline are handled. 'Reject' blocks them, 'Flag' accepts but marks as late, 'Accept' treats them normally." />
</div>
<Select <Select
value={config.lateSubmissionPolicy ?? 'flag'} value={config.lateSubmissionPolicy ?? 'flag'}
onValueChange={(value) => onValueChange={(value) =>
@ -108,7 +175,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && ( {(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Grace Period (hours)</Label> <Label>Grace Period (hours)</Label>
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
</div>
<Input <Input
type="number" type="number"
min={0} min={0}
@ -125,7 +195,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
{/* File Requirements */} {/* File Requirements */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label>File Requirements</Label> <Label>File Requirements</Label>
<InfoTooltip content="Define what files applicants must upload. Each requirement can specify accepted formats and size limits." />
</div>
<Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}> <Button type="button" variant="outline" size="sm" onClick={addFileReq} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />
Add Requirement Add Requirement
@ -187,6 +260,14 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
<Label className="text-xs">Required</Label> <Label className="text-xs">Required</Label>
</div> </div>
</div> </div>
<div className="sm:col-span-2">
<FileTypePicker
value={req.acceptedMimeTypes}
onChange={(mimeTypes) =>
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
}
/>
</div>
</div> </div>
<Button <Button
type="button" type="button"

View File

@ -10,6 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { LiveFinalConfig } from '@/types/pipeline-wizard' import type { LiveFinalConfig } from '@/types/pipeline-wizard'
type LiveFinalsSectionProps = { type LiveFinalsSectionProps = {
@ -27,7 +28,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Label>Jury Voting</Label> <Label>Jury Voting</Label>
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow jury members to vote during the live finals event Allow jury members to vote during the live finals event
</p> </p>
@ -44,7 +48,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5">
<Label>Audience Voting</Label> <Label>Audience Voting</Label>
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow audience members to vote on projects Allow audience members to vote on projects
</p> </p>
@ -61,7 +68,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
{(config.audienceVotingEnabled ?? false) && ( {(config.audienceVotingEnabled ?? false) && (
<div className="pl-4 border-l-2 border-muted space-y-3"> <div className="pl-4 border-l-2 border-muted space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs">Audience Vote Weight</Label> <Label className="text-xs">Audience Vote Weight</Label>
<InfoTooltip content="Percentage weight of audience votes vs jury votes in the final score (e.g., 30 means 30% audience, 70% jury)." />
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Slider <Slider
value={[(config.audienceVoteWeight ?? 0) * 100]} value={[(config.audienceVoteWeight ?? 0) * 100]}
@ -86,7 +96,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Cohort Setup Mode</Label> <Label>Cohort Setup Mode</Label>
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
</div>
<Select <Select
value={config.cohortSetupMode ?? 'manual'} value={config.cohortSetupMode ?? 'manual'}
onValueChange={(value) => onValueChange={(value) =>
@ -111,7 +124,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label>Result Reveal Policy</Label> <Label>Result Reveal Policy</Label>
<InfoTooltip content="Immediate: show results as votes come in. Delayed: reveal after all votes. Ceremony: reveal during a dedicated announcement." />
</div>
<Select <Select
value={config.revealPolicy ?? 'ceremony'} value={config.revealPolicy ?? 'ceremony'}
onValueChange={(value) => onValueChange={(value) =>

View File

@ -21,6 +21,7 @@ import {
ChevronUp, ChevronUp,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import type { WizardStageConfig } from '@/types/pipeline-wizard' import type { WizardStageConfig } from '@/types/pipeline-wizard'
import type { StageType } from '@prisma/client' import type { StageType } from '@prisma/client'
@ -91,10 +92,16 @@ export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectio
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-1.5 mb-1">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Define the stages projects flow through in the main competition track. Define the stages projects flow through in the main competition track.
Drag to reorder. Minimum 2 stages required. Drag to reorder. Minimum 2 stages required.
</p> </p>
<InfoTooltip
content="INTAKE: Collect project submissions. FILTER: Automated screening. EVALUATION: Jury review and scoring. SELECTION: Choose finalists. LIVE_FINAL: Live ceremony voting. RESULTS: Publish outcomes."
side="right"
/>
</div>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}> <Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />

View File

@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
type NotificationsSectionProps = { type NotificationsSectionProps = {
config: Record<string, boolean> config: Record<string, boolean>
@ -69,10 +70,11 @@ export function NotificationsSection({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex items-center gap-1.5">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose which pipeline events trigger notifications. All events are enabled by default. Choose which pipeline events trigger notifications. All events are enabled by default.
</p> </p>
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -3,6 +3,7 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react' import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { validateAll } from '@/lib/pipeline-validation' import { validateAll } from '@/lib/pipeline-validation'
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard' import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
@ -97,7 +98,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
{/* Validation Checks */} {/* Validation Checks */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Validation Checks</CardTitle> <CardTitle className="text-sm">Validation Checks</CardTitle>
<InfoTooltip content="Automated checks that verify all required fields are filled and configuration is consistent before saving." />
</div>
</CardHeader> </CardHeader>
<CardContent className="divide-y"> <CardContent className="divide-y">
<ValidationSection label="Basics" result={validation.sections.basics} /> <ValidationSection label="Basics" result={validation.sections.basics} />
@ -109,7 +113,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
{/* Structure Summary */} {/* Structure Summary */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-1.5">
<CardTitle className="text-sm">Structure Summary</CardTitle> <CardTitle className="text-sm">Structure Summary</CardTitle>
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">

View File

@ -122,11 +122,16 @@ export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
)} )}
{config?.aiRubricEnabled && ( {config?.aiRubricEnabled && (
<div className="mt-3 pt-3 border-t"> <div className="mt-3 pt-3 border-t space-y-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%, AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%) Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
</p> </p>
{config.aiCriteriaText && (
<p className="text-xs text-muted-foreground line-clamp-2">
Criteria: {config.aiCriteriaText}
</p>
)}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@ -1,5 +1,7 @@
'use client' 'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -111,15 +113,18 @@ export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{projectStates.items.map((ps) => ( {projectStates.items.map((ps) => (
<div <Link
key={ps.id} key={ps.id}
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0" href={`/admin/projects/${ps.project.id}` as Route}
className="block"
> >
<div className="flex items-center justify-between text-sm py-1.5 border-b last:border-0 hover:bg-muted/50 cursor-pointer rounded-md px-1 transition-colors">
<span className="truncate">{ps.project.title}</span> <span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0"> <Badge variant="outline" className="text-[10px] shrink-0">
{ps.state} {ps.state}
</Badge> </Badge>
</div> </div>
</Link>
))} ))}
</div> </div>
)} )}

View File

@ -8,11 +8,12 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react' import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react'
type WizardSectionProps = { type WizardSectionProps = {
title: string title: string
description?: string description?: string
helpText?: string
stepNumber: number stepNumber: number
isOpen: boolean isOpen: boolean
onToggle: () => void onToggle: () => void
@ -24,6 +25,7 @@ type WizardSectionProps = {
export function WizardSection({ export function WizardSection({
title, title,
description, description,
helpText,
stepNumber, stepNumber,
isOpen, isOpen,
onToggle, onToggle,
@ -74,7 +76,15 @@ export function WizardSection({
</CardHeader> </CardHeader>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="pt-0">{children}</CardContent> <CardContent className="pt-0">
{helpText && (
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300 text-sm rounded-md p-3 mb-4 flex items-start gap-2">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>{helpText}</span>
</div>
)}
{children}
</CardContent>
</CollapsibleContent> </CollapsibleContent>
</Card> </Card>
</Collapsible> </Collapsible>

View File

@ -0,0 +1,36 @@
'use client'
import { Info } from 'lucide-react'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/tooltip'
type InfoTooltipProps = {
content: string
side?: 'top' | 'right' | 'bottom' | 'left'
}
export function InfoTooltip({ content, side = 'top' }: InfoTooltipProps) {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={0}
className="inline-flex items-center justify-center rounded-full text-muted-foreground hover:text-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
>
<Info className="h-4 w-4" />
<span className="sr-only">More info</span>
</button>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-xs text-sm">
{content}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@ -0,0 +1,29 @@
export type FileTypeCategory = {
id: string
label: string
mimeTypes: string[]
extensions: string[]
}
export const FILE_TYPE_CATEGORIES: FileTypeCategory[] = [
{ id: 'pdf', label: 'PDF', mimeTypes: ['application/pdf'], extensions: ['.pdf'] },
{ id: 'word', label: 'Word', mimeTypes: ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], extensions: ['.doc', '.docx'] },
{ id: 'powerpoint', label: 'PowerPoint', mimeTypes: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'], extensions: ['.ppt', '.pptx'] },
{ id: 'excel', label: 'Excel', mimeTypes: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], extensions: ['.xls', '.xlsx'] },
{ id: 'images', label: 'Images', mimeTypes: ['image/*'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'] },
{ id: 'videos', label: 'Videos', mimeTypes: ['video/*'], extensions: ['.mp4', '.mov', '.avi', '.webm'] },
]
/** Get active category IDs from a list of mime types */
export function getActiveCategoriesFromMimeTypes(mimeTypes: string[]): string[] {
return FILE_TYPE_CATEGORIES.filter((cat) =>
cat.mimeTypes.some((mime) => mimeTypes.includes(mime))
).map((cat) => cat.id)
}
/** Convert category IDs to flat mime type array */
export function categoriesToMimeTypes(categoryIds: string[]): string[] {
return FILE_TYPE_CATEGORIES.filter((cat) => categoryIds.includes(cat.id)).flatMap(
(cat) => cat.mimeTypes
)
}

View File

@ -31,6 +31,7 @@ export function defaultFilterConfig(): FilterConfig {
return { return {
rules: [], rules: [],
aiRubricEnabled: false, aiRubricEnabled: false,
aiCriteriaText: '',
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 }, aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
manualQueueEnabled: true, manualQueueEnabled: true,
} }

View File

@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { router, observerProcedure } from '../trpc' import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
const editionOrStageInput = z.object({ const editionOrStageInput = z.object({
stageId: z.string().optional(), stageId: z.string().optional(),
@ -384,9 +385,16 @@ export const analyticsRouter = router({
_count: { id: true }, _count: { id: true },
}) })
return distribution.map((d) => ({ // Resolve country names to ISO codes (DB may store "France" instead of "FR")
countryCode: d.country || 'UNKNOWN', const codeMap = new Map<string, number>()
count: d._count.id, for (const d of distribution) {
const resolved = normalizeCountryToCode(d.country) ?? d.country ?? 'UNKNOWN'
codeMap.set(resolved, (codeMap.get(resolved) ?? 0) + d._count.id)
}
return Array.from(codeMap.entries()).map(([countryCode, count]) => ({
countryCode,
count,
})) }))
}), }),

View File

@ -96,10 +96,19 @@ async function runAIAssignmentJob(jobId: string, stageId: string, userId: string
}) })
} }
// Build per-juror limits map for jurors with personal maxAssignments
const jurorLimits: Record<string, number> = {}
for (const juror of jurors) {
if (juror.maxAssignments !== null && juror.maxAssignments !== undefined) {
jurorLimits[juror.id] = juror.maxAssignments
}
}
const constraints = { const constraints = {
requiredReviewsPerProject: requiredReviews, requiredReviewsPerProject: requiredReviews,
minAssignmentsPerJuror, minAssignmentsPerJuror,
maxAssignmentsPerJuror, maxAssignmentsPerJuror,
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
existingAssignments: existingAssignments.map((a) => ({ existingAssignments: existingAssignments.map((a) => ({
jurorId: a.userId, jurorId: a.userId,
projectId: a.projectId, projectId: a.projectId,
@ -420,8 +429,58 @@ export const assignmentRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Fetch per-juror maxAssignments and current counts for capacity checking
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
const users = await ctx.prisma.user.findMany({
where: { id: { in: uniqueUserIds } },
select: {
id: true,
name: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
// Get stage default max
const stage = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true, name: true, windowCloseAt: true },
})
const config = (stage.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
// Track running counts to handle multiple assignments to the same juror in one batch
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
// Filter out assignments that would exceed a juror's limit
let skippedDueToCapacity = 0
const allowedAssignments = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true // unknown user, let createMany handle it
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
// Increment running count for subsequent assignments to same user
runningCounts.set(a.userId, currentCount + 1)
return true
})
const result = await ctx.prisma.assignment.createMany({ const result = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({ data: allowedAssignments.map((a) => ({
...a, ...a,
stageId: input.stageId, stageId: input.stageId,
method: 'BULK', method: 'BULK',
@ -436,15 +495,19 @@ export const assignmentRouter = router({
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_CREATE', action: 'BULK_CREATE',
entityType: 'Assignment', entityType: 'Assignment',
detailsJson: { count: result.count }, detailsJson: {
count: result.count,
requested: input.assignments.length,
skippedDueToCapacity,
},
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
// Send notifications to assigned jury members (grouped by user) // Send notifications to assigned jury members (grouped by user)
if (result.count > 0 && input.assignments.length > 0) { if (result.count > 0 && allowedAssignments.length > 0) {
// Group assignments by user to get counts // Group assignments by user to get counts
const userAssignmentCounts = input.assignments.reduce( const userAssignmentCounts = allowedAssignments.reduce(
(acc, a) => { (acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1 acc[a.userId] = (acc[a.userId] || 0) + 1
return acc return acc
@ -452,11 +515,6 @@ export const assignmentRouter = router({
{} as Record<string, number> {} as Record<string, number>
) )
const stage = await ctx.prisma.stage.findUnique({
where: { id: input.stageId },
select: { name: true, windowCloseAt: true },
})
const deadline = stage?.windowCloseAt const deadline = stage?.windowCloseAt
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', { ? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
@ -495,6 +553,7 @@ export const assignmentRouter = router({
created: result.count, created: result.count,
requested: input.assignments.length, requested: input.assignments.length,
skipped: input.assignments.length - result.count, skipped: input.assignments.length - result.count,
skippedDueToCapacity,
} }
}), }),
@ -826,11 +885,61 @@ export const assignmentRouter = router({
}) })
), ),
usedAI: z.boolean().default(false), usedAI: z.boolean().default(false),
forceOverride: z.boolean().default(false),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
let assignmentsToCreate = input.assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if (!input.forceOverride) {
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
const users = await ctx.prisma.user.findMany({
where: { id: { in: uniqueUserIds } },
select: {
id: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({ const created = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({ data: assignmentsToCreate.map((a) => ({
userId: a.userId, userId: a.userId,
projectId: a.projectId, projectId: a.projectId,
stageId: input.stageId, stageId: input.stageId,
@ -852,13 +961,15 @@ export const assignmentRouter = router({
stageId: input.stageId, stageId: input.stageId,
count: created.count, count: created.count,
usedAI: input.usedAI, usedAI: input.usedAI,
forceOverride: input.forceOverride,
skippedDueToCapacity,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
if (created.count > 0) { if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce( const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => { (acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1 acc[a.userId] = (acc[a.userId] || 0) + 1
return acc return acc
@ -905,7 +1016,11 @@ export const assignmentRouter = router({
} }
} }
return { created: created.count } return {
created: created.count,
requested: input.assignments.length,
skippedDueToCapacity,
}
}), }),
/** /**
@ -922,11 +1037,61 @@ export const assignmentRouter = router({
reasoning: z.string().optional(), reasoning: z.string().optional(),
}) })
), ),
forceOverride: z.boolean().default(false),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
let assignmentsToCreate = input.assignments
let skippedDueToCapacity = 0
// Capacity check (unless forceOverride)
if (!input.forceOverride) {
const uniqueUserIds = [...new Set(input.assignments.map((a) => a.userId))]
const users = await ctx.prisma.user.findMany({
where: { id: { in: uniqueUserIds } },
select: {
id: true,
maxAssignments: true,
_count: {
select: {
assignments: { where: { stageId: input.stageId } },
},
},
},
})
const userMap = new Map(users.map((u) => [u.id, u]))
const stageData = await ctx.prisma.stage.findUniqueOrThrow({
where: { id: input.stageId },
select: { configJson: true },
})
const config = (stageData.configJson ?? {}) as Record<string, unknown>
const stageMaxPerJuror = (config.maxAssignmentsPerJuror as number) ?? 20
const runningCounts = new Map<string, number>()
for (const u of users) {
runningCounts.set(u.id, u._count.assignments)
}
assignmentsToCreate = input.assignments.filter((a) => {
const user = userMap.get(a.userId)
if (!user) return true
const effectiveMax = user.maxAssignments ?? stageMaxPerJuror
const currentCount = runningCounts.get(a.userId) ?? 0
if (currentCount >= effectiveMax) {
skippedDueToCapacity++
return false
}
runningCounts.set(a.userId, currentCount + 1)
return true
})
}
const created = await ctx.prisma.assignment.createMany({ const created = await ctx.prisma.assignment.createMany({
data: input.assignments.map((a) => ({ data: assignmentsToCreate.map((a) => ({
userId: a.userId, userId: a.userId,
projectId: a.projectId, projectId: a.projectId,
stageId: input.stageId, stageId: input.stageId,
@ -945,13 +1110,15 @@ export const assignmentRouter = router({
detailsJson: { detailsJson: {
stageId: input.stageId, stageId: input.stageId,
count: created.count, count: created.count,
forceOverride: input.forceOverride,
skippedDueToCapacity,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}) })
if (created.count > 0) { if (created.count > 0) {
const userAssignmentCounts = input.assignments.reduce( const userAssignmentCounts = assignmentsToCreate.reduce(
(acc, a) => { (acc, a) => {
acc[a.userId] = (acc[a.userId] || 0) + 1 acc[a.userId] = (acc[a.userId] || 0) + 1
return acc return acc
@ -998,7 +1165,11 @@ export const assignmentRouter = router({
} }
} }
return { created: created.count } return {
created: created.count,
requested: input.assignments.length,
skippedDueToCapacity,
}
}), }),
/** /**

View File

@ -696,6 +696,132 @@ export const fileRouter = router({
return results return results
}), }),
/**
* Get file requirements for a project from its pipeline's intake stage.
* Returns both configJson-based requirements and actual FileRequirement records,
* along with which ones are already fulfilled by uploaded files.
*/
getProjectRequirements: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// 1. Get the project and its program
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
select: { programId: true },
})
// 2. Find the pipeline for this program
const pipeline = await ctx.prisma.pipeline.findFirst({
where: { programId: project.programId },
include: {
tracks: {
where: { kind: 'MAIN' },
include: {
stages: {
where: { stageType: 'INTAKE' },
take: 1,
},
},
},
},
})
if (!pipeline) return null
const mainTrack = pipeline.tracks[0]
if (!mainTrack) return null
const intakeStage = mainTrack.stages[0]
if (!intakeStage) return null
// 3. Check for actual FileRequirement records first
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
where: { stageId: intakeStage.id },
orderBy: { sortOrder: 'asc' },
include: {
files: {
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
},
},
})
// 4. If we have DB requirements, return those (they're the canonical source)
if (dbRequirements.length > 0) {
return {
stageId: intakeStage.id,
requirements: dbRequirements.map((req) => ({
id: req.id,
name: req.name,
description: req.description,
acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB,
isRequired: req.isRequired,
fulfilled: req.files.length > 0,
fulfilledFile: req.files[0] ?? null,
})),
}
}
// 5. Fall back to configJson requirements
const configJson = intakeStage.configJson as Record<string, unknown> | null
const fileRequirements = (configJson?.fileRequirements as Array<{
name: string
description?: string
acceptedMimeTypes?: string[]
maxSizeMB?: number
isRequired?: boolean
type?: string
required?: boolean
}>) ?? []
if (fileRequirements.length === 0) return null
// 6. Get project files to check fulfillment
const projectFiles = await ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId },
select: {
id: true,
fileName: true,
fileType: true,
mimeType: true,
size: true,
createdAt: true,
},
})
return {
stageId: intakeStage.id,
requirements: fileRequirements.map((req) => {
const reqName = req.name.toLowerCase()
// Match by checking if any uploaded file's fileName contains the requirement name
const matchingFile = projectFiles.find((f) =>
f.fileName.toLowerCase().includes(reqName) ||
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
)
return {
id: null as string | null,
name: req.name,
description: req.description ?? null,
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
maxSizeMB: req.maxSizeMB ?? null,
// Handle both formats: isRequired (wizard type) and required (seed data)
isRequired: req.isRequired ?? req.required ?? false,
fulfilled: !!matchingFile,
fulfilledFile: matchingFile ?? null,
}
}),
}
}),
// ========================================================================= // =========================================================================
// FILE REQUIREMENTS // FILE REQUIREMENTS
// ========================================================================= // =========================================================================

View File

@ -316,7 +316,7 @@ export const pipelineRouter = router({
createStructure: adminProcedure createStructure: adminProcedure
.input( .input(
z.object({ z.object({
programId: z.string(), programId: z.string().min(1, 'Program ID is required'),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
settingsJson: z.record(z.unknown()).optional(), settingsJson: z.record(z.unknown()).optional(),

View File

@ -267,22 +267,41 @@ export const stageAssignmentRouter = router({
_count: true, _count: true,
}) })
// Fetch per-juror maxAssignments for all jurors involved
const allJurorIds = jurorLoads.map((j) => j.userId)
const jurorUsers = await ctx.prisma.user.findMany({
where: { id: { in: allJurorIds } },
select: { id: true, maxAssignments: true },
})
const jurorMaxMap = new Map(jurorUsers.map((u) => [u.id, u.maxAssignments]))
const overLoaded = jurorLoads.filter( const overLoaded = jurorLoads.filter(
(j) => j._count > input.targetPerJuror (j) => j._count > input.targetPerJuror
) )
const underLoaded = jurorLoads.filter(
(j) => j._count < input.targetPerJuror
)
// Calculate how many can be moved // For under-loaded jurors, also check they haven't hit their personal maxAssignments
const underLoaded = jurorLoads.filter((j) => {
if (j._count >= input.targetPerJuror) return false
const userMax = jurorMaxMap.get(j.userId)
// If user has a personal max and is already at it, they can't receive more
if (userMax !== null && userMax !== undefined && j._count >= userMax) {
return false
}
return true
})
// Calculate how many can be moved, respecting per-juror limits
const excessTotal = overLoaded.reduce( const excessTotal = overLoaded.reduce(
(sum, j) => sum + (j._count - input.targetPerJuror), (sum, j) => sum + (j._count - input.targetPerJuror),
0 0
) )
const capacityTotal = underLoaded.reduce( const capacityTotal = underLoaded.reduce((sum, j) => {
(sum, j) => sum + (input.targetPerJuror - j._count), const userMax = jurorMaxMap.get(j.userId)
0 const effectiveTarget = (userMax !== null && userMax !== undefined)
) ? Math.min(input.targetPerJuror, userMax)
: input.targetPerJuror
return sum + Math.max(0, effectiveTarget - j._count)
}, 0)
const movableCount = Math.min(excessTotal, capacityTotal) const movableCount = Math.min(excessTotal, capacityTotal)
if (input.dryRun) { if (input.dryRun) {
@ -322,7 +341,12 @@ export const stageAssignmentRouter = router({
for (const assignment of assignmentsToMove) { for (const assignment of assignmentsToMove) {
// Find an under-loaded juror who doesn't already have this project // Find an under-loaded juror who doesn't already have this project
for (const under of underLoaded) { for (const under of underLoaded) {
if (under._count >= input.targetPerJuror) continue // Respect both target and personal maxAssignments
const userMax = jurorMaxMap.get(under.userId)
const effectiveCapacity = (userMax !== null && userMax !== undefined)
? Math.min(input.targetPerJuror, userMax)
: input.targetPerJuror
if (under._count >= effectiveCapacity) continue
// Check no existing assignment for this juror-project pair // Check no existing assignment for this juror-project pair
const exists = await tx.assignment.findFirst({ const exists = await tx.assignment.findFirst({

View File

@ -80,6 +80,7 @@ interface AssignmentConstraints {
requiredReviewsPerProject: number requiredReviewsPerProject: number
minAssignmentsPerJuror?: number minAssignmentsPerJuror?: number
maxAssignmentsPerJuror?: number maxAssignmentsPerJuror?: number
jurorLimits?: Record<string, number> // userId -> personal max assignments
existingAssignments: Array<{ existingAssignments: Array<{
jurorId: string jurorId: string
projectId: string projectId: string
@ -260,9 +261,24 @@ function buildBatchPrompt(
})) }))
.filter((a) => a.jurorId && a.projectId) .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)} return `JURORS: ${JSON.stringify(jurors)}
PROJECTS: ${JSON.stringify(projects)} 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)} EXISTING: ${JSON.stringify(anonymousExisting)}
Return JSON: {"assignments": [...]}` Return JSON: {"assignments": [...]}`
} }

View File

@ -419,12 +419,20 @@ export async function getSmartSuggestions(options: {
const suggestions: AssignmentScore[] = [] const suggestions: AssignmentScore[] = []
for (const user of users) { for (const user of users) {
// Skip users at AI max (they won't appear in suggestions)
const currentCount = user._count.assignments const currentCount = user._count.assignments
// Skip users at AI max (they won't appear in suggestions)
if (currentCount >= aiMaxPerJudge) { if (currentCount >= aiMaxPerJudge) {
continue 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) { for (const project of projects) {
// Skip if already assigned // Skip if already assigned
const pairKey = `${user.id}:${project.id}` const pairKey = `${user.id}:${project.id}`
@ -621,6 +629,13 @@ export async function getMentorSuggestionsForProject(
continue 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( const { score: tagScore, matchingTags } = calculateTagOverlapScore(
mentor.expertiseTags, mentor.expertiseTags,
projectTags projectTags

View File

@ -22,6 +22,7 @@ export type FileRequirementConfig = {
export type FilterConfig = { export type FilterConfig = {
rules: FilterRuleConfig[] rules: FilterRuleConfig[]
aiRubricEnabled: boolean aiRubricEnabled: boolean
aiCriteriaText: string
aiConfidenceThresholds: { aiConfidenceThresholds: {
high: number high: number
medium: number medium: number