Platform polish: bulk invite, file requirements, filtering redesign, UX fixes
Build and Push Docker Image / build (push) Successful in 11m41s
Details
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:
parent
451b483880
commit
70cfad7d46
|
|
@ -186,7 +186,6 @@ async function main() {
|
|||
|
||||
const juryUserIds: string[] = []
|
||||
for (const j of juryMembers) {
|
||||
const passwordHash = await bcrypt.hash('Jury2026!', 12)
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: j.email },
|
||||
update: {},
|
||||
|
|
@ -194,11 +193,9 @@ async function main() {
|
|||
email: j.email,
|
||||
name: j.name,
|
||||
role: UserRole.JURY_MEMBER,
|
||||
status: UserStatus.ACTIVE,
|
||||
status: UserStatus.NONE,
|
||||
country: j.country,
|
||||
expertiseTags: j.tags,
|
||||
passwordHash,
|
||||
mustSetPassword: true,
|
||||
bio: `Expert in ${j.tags.join(', ')}`,
|
||||
},
|
||||
})
|
||||
|
|
@ -218,7 +215,6 @@ async function main() {
|
|||
]
|
||||
|
||||
for (const m of mentors) {
|
||||
const passwordHash = await bcrypt.hash('Mentor2026!', 12)
|
||||
await prisma.user.upsert({
|
||||
where: { email: m.email },
|
||||
update: {},
|
||||
|
|
@ -226,11 +222,9 @@ async function main() {
|
|||
email: m.email,
|
||||
name: m.name,
|
||||
role: UserRole.MENTOR,
|
||||
status: UserStatus.ACTIVE,
|
||||
status: UserStatus.NONE,
|
||||
country: m.country,
|
||||
expertiseTags: m.tags,
|
||||
passwordHash,
|
||||
mustSetPassword: true,
|
||||
},
|
||||
})
|
||||
console.log(` ✓ Mentor: ${m.name}`)
|
||||
|
|
@ -247,7 +241,6 @@ async function main() {
|
|||
]
|
||||
|
||||
for (const o of observers) {
|
||||
const passwordHash = await bcrypt.hash('Observer2026!', 12)
|
||||
await prisma.user.upsert({
|
||||
where: { email: o.email },
|
||||
update: {},
|
||||
|
|
@ -255,10 +248,8 @@ async function main() {
|
|||
email: o.email,
|
||||
name: o.name,
|
||||
role: UserRole.OBSERVER,
|
||||
status: UserStatus.ACTIVE,
|
||||
status: UserStatus.NONE,
|
||||
country: o.country,
|
||||
passwordHash,
|
||||
mustSetPassword: true,
|
||||
},
|
||||
})
|
||||
console.log(` ✓ Observer: ${o.name}`)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import {
|
|||
Calendar,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
|
|
@ -86,6 +87,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
// Fetch files (flat list for backward compatibility)
|
||||
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)
|
||||
const { data: programData } = trpc.program.get.useQuery(
|
||||
{ id: project?.programId || '' },
|
||||
|
|
@ -521,7 +528,90 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
|
|
@ -534,22 +624,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-fin
|
|||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-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 { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
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() {
|
||||
const router = useRouter()
|
||||
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 [openSection, setOpenSection] = useState(0)
|
||||
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
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
|
|
@ -161,6 +170,26 @@ export default function NewPipelinePage() {
|
|||
|
||||
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 = [
|
||||
{
|
||||
title: 'Basics',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -40,6 +42,7 @@ const statusColors: Record<string, string> = {
|
|||
}
|
||||
|
||||
export default function PipelineListPage() {
|
||||
const router = useRouter()
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
|
||||
|
|
@ -48,6 +51,13 @@ export default function PipelineListPage() {
|
|||
{ 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams, usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -27,9 +28,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
||||
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 { formatRelativeTime } from '@/lib/utils'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins'
|
||||
|
|
@ -131,6 +133,8 @@ export function MembersContent() {
|
|||
|
||||
const roles = TAB_ROLES[tab]
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: currentUser } = trpc.user.me.useQuery()
|
||||
const currentUserRole = currentUser?.role as RoleValue | undefined
|
||||
|
||||
|
|
@ -141,6 +145,75 @@ export function MembersContent() {
|
|||
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) => {
|
||||
updateParams({ tab: value === 'all' ? null : value, page: '1' })
|
||||
}
|
||||
|
|
@ -197,6 +270,15 @@ export function MembersContent() {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<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>Role</TableHead>
|
||||
<TableHead>Expertise</TableHead>
|
||||
|
|
@ -209,6 +291,17 @@ export function MembersContent() {
|
|||
<TableBody>
|
||||
{data.users.map((user) => (
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
|
|
@ -297,6 +390,14 @@ export function MembersContent() {
|
|||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<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
|
||||
user={user}
|
||||
avatarUrl={(user as Record<string, unknown>).avatarUrl as string | undefined}
|
||||
|
|
@ -395,6 +496,50 @@ export function MembersContent() {
|
|||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type AssignmentSectionProps = {
|
||||
|
|
@ -27,7 +28,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
|||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Required Reviews per Project</Label>
|
||||
<InfoTooltip content="Number of independent jury evaluations needed per project before it can be decided." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -44,7 +48,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Max Load per Juror</Label>
|
||||
<InfoTooltip content="Maximum number of projects a single juror can be assigned in this stage." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -61,7 +68,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Min Load per Juror</Label>
|
||||
<InfoTooltip content="Minimum target assignments per juror. The system prioritizes jurors below this threshold." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
|
|
@ -86,7 +96,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
Factor in juror availability when assigning projects
|
||||
</p>
|
||||
|
|
@ -101,7 +114,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Overflow Policy</Label>
|
||||
<InfoTooltip content="'Queue' holds excess projects, 'Expand Pool' invites more jurors, 'Reduce Reviews' lowers the required review count." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.overflowPolicy ?? 'queue'}
|
||||
onValueChange={(value) =>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2, Trophy } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
|
||||
|
|
@ -146,7 +147,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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
|
||||
value={track.routingModeDefault ?? 'PARALLEL'}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -176,7 +180,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Decision Mode</Label>
|
||||
<InfoTooltip content="How the winner is determined for this award track." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.decisionMode ?? 'JURY_VOTE'}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -197,7 +204,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
|
|||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs">Scoring Mode</Label>
|
||||
<InfoTooltip content="The method used to aggregate scores for this award." />
|
||||
</div>
|
||||
<Select
|
||||
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
|
||||
onValueChange={(value) =>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { WizardState } from '@/types/pipeline-wizard'
|
||||
|
||||
|
|
@ -52,7 +53,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="pipeline-slug">Slug</Label>
|
||||
<InfoTooltip content="URL-friendly identifier. Cannot be changed after the pipeline is activated." />
|
||||
</div>
|
||||
<Input
|
||||
id="pipeline-slug"
|
||||
placeholder="e.g., mopc-2026"
|
||||
|
|
@ -70,7 +74,10 @@ export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps)
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="pipeline-program">Program</Label>
|
||||
<InfoTooltip content="The program edition this pipeline belongs to. Each program can have multiple pipelines." />
|
||||
</div>
|
||||
<Select
|
||||
value={state.programId}
|
||||
onValueChange={(value) => onChange({ programId: value })}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -13,21 +15,102 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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'
|
||||
|
||||
// ─── 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 = {
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function FilteringSection({ config, onChange, isActive }: FilteringSectionProps) {
|
||||
const [rulesOpen, setRulesOpen] = useState(false)
|
||||
const [aiFieldsOpen, setAiFieldsOpen] = useState(false)
|
||||
|
||||
const updateConfig = (updates: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
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 updated = [...rules]
|
||||
|
|
@ -40,7 +123,7 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
|
|||
...config,
|
||||
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) })
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Deterministic Gate Rules */}
|
||||
<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 */}
|
||||
{/* ── AI Screening (Primary) ────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
<Label>AI Screening</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to evaluate projects against rubric criteria
|
||||
<InfoTooltip content="Uses AI to evaluate projects against your criteria in natural language. Results are suggestions, not final decisions." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Use AI to evaluate projects against your screening criteria
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -143,15 +163,97 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
|
|||
|
||||
{config.aiRubricEnabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Criteria Textarea (THE KEY MISSING PIECE) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">High Confidence Threshold</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm font-medium">Screening Criteria</Label>
|
||||
<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
|
||||
value={[(config.aiConfidenceThresholds?.high ?? 0.85) * 100]}
|
||||
value={[highPct]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }),
|
||||
...thresholds,
|
||||
high: v / 100,
|
||||
},
|
||||
})
|
||||
|
|
@ -159,22 +261,25 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
|
|||
min={50}
|
||||
max={100}
|
||||
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>
|
||||
|
||||
{/* 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 className="space-y-2">
|
||||
<Label className="text-xs">Medium Confidence Threshold</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[(config.aiConfidenceThresholds?.medium ?? 0.6) * 100]}
|
||||
value={[medPct]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...(config.aiConfidenceThresholds ?? { high: 0.85, medium: 0.6, low: 0.4 }),
|
||||
...thresholds,
|
||||
medium: v / 100,
|
||||
},
|
||||
})
|
||||
|
|
@ -182,21 +287,21 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
|
|||
min={20}
|
||||
max={80}
|
||||
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>
|
||||
|
||||
{/* Manual Queue */}
|
||||
{/* ── Manual Review Queue ────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
Projects below medium confidence go to manual review
|
||||
</p>
|
||||
|
|
@ -207,6 +312,168 @@ export function FilteringSection({ config, onChange, isActive }: FilteringSectio
|
|||
disabled={isActive}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,69 @@ import {
|
|||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus, Trash2, FileText } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
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 = {
|
||||
config: IntakeConfig
|
||||
|
|
@ -67,7 +128,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
Enable timed submission windows for project intake
|
||||
</p>
|
||||
|
|
@ -85,7 +149,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
|||
{/* Late Policy */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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
|
||||
value={config.lateSubmissionPolicy ?? 'flag'}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -108,7 +175,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
|||
|
||||
{(config.lateSubmissionPolicy ?? 'flag') === 'flag' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Grace Period (hours)</Label>
|
||||
<InfoTooltip content="Extra time after the deadline during which late submissions are still accepted but flagged." />
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
|
|
@ -125,7 +195,10 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
|||
{/* File Requirements */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Requirement
|
||||
|
|
@ -187,6 +260,14 @@ export function IntakeSection({ config, onChange, isActive }: IntakeSectionProps
|
|||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<FileTypePicker
|
||||
value={req.acceptedMimeTypes}
|
||||
onChange={(mimeTypes) =>
|
||||
updateFileReq(index, { acceptedMimeTypes: mimeTypes })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type LiveFinalsSectionProps = {
|
||||
|
|
@ -27,7 +28,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Jury Voting</Label>
|
||||
<InfoTooltip content="Enable jury members to cast votes during the live ceremony." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jury members to vote during the live finals event
|
||||
</p>
|
||||
|
|
@ -44,7 +48,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Audience Voting</Label>
|
||||
<InfoTooltip content="Allow audience members to participate in voting alongside the jury." />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow audience members to vote on projects
|
||||
</p>
|
||||
|
|
@ -61,7 +68,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
|
|||
{(config.audienceVotingEnabled ?? false) && (
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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">
|
||||
<Slider
|
||||
value={[(config.audienceVoteWeight ?? 0) * 100]}
|
||||
|
|
@ -86,7 +96,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>Cohort Setup Mode</Label>
|
||||
<InfoTooltip content="Auto: system assigns projects to presentation groups. Manual: admin defines cohorts." />
|
||||
</div>
|
||||
<Select
|
||||
value={config.cohortSetupMode ?? 'manual'}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -111,7 +124,10 @@ export function LiveFinalsSection({ config, onChange, isActive }: LiveFinalsSect
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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
|
||||
value={config.revealPolicy ?? 'ceremony'}
|
||||
onValueChange={(value) =>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import type { WizardStageConfig } from '@/types/pipeline-wizard'
|
||||
import type { StageType } from '@prisma/client'
|
||||
|
||||
|
|
@ -91,10 +92,16 @@ export function MainTrackSection({ stages, onChange, isActive }: MainTrackSectio
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the stages projects flow through in the main competition track.
|
||||
Drag to reorder. Minimum 2 stages required.
|
||||
</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>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addStage} disabled={isActive}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label'
|
|||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
|
||||
type NotificationsSectionProps = {
|
||||
config: Record<string, boolean>
|
||||
|
|
@ -69,10 +70,11 @@ export function NotificationsSection({
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which pipeline events trigger notifications. All events are enabled by default.
|
||||
</p>
|
||||
<InfoTooltip content="Configure email notifications for pipeline events. Each event type can be individually enabled or disabled." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { validateAll } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
|
||||
|
|
@ -97,7 +98,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
|
|||
{/* Validation Checks */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
<CardContent className="divide-y">
|
||||
<ValidationSection label="Basics" result={validation.sections.basics} />
|
||||
|
|
@ -109,7 +113,10 @@ export function ReviewSection({ state }: ReviewSectionProps) {
|
|||
{/* Structure Summary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className="text-sm">Structure Summary</CardTitle>
|
||||
<InfoTooltip content="Overview of the pipeline structure showing total tracks, stages, transitions, and notification settings." />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
|
|
|
|||
|
|
@ -122,11 +122,16 @@ export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
|
|||
)}
|
||||
|
||||
{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">
|
||||
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
|
||||
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
|
||||
</p>
|
||||
{config.aiCriteriaText && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
Criteria: {config.aiCriteriaText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -111,15 +113,18 @@ export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
|
|||
) : (
|
||||
<div className="space-y-1">
|
||||
{projectStates.items.map((ps) => (
|
||||
<div
|
||||
<Link
|
||||
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>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{ps.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import {
|
|||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { ChevronDown, CheckCircle2, AlertCircle, Info } from 'lucide-react'
|
||||
|
||||
type WizardSectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
helpText?: string
|
||||
stepNumber: number
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
|
|
@ -24,6 +25,7 @@ type WizardSectionProps = {
|
|||
export function WizardSection({
|
||||
title,
|
||||
description,
|
||||
helpText,
|
||||
stepNumber,
|
||||
isOpen,
|
||||
onToggle,
|
||||
|
|
@ -74,7 +76,15 @@ export function WizardSection({
|
|||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<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>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ export function defaultFilterConfig(): FilterConfig {
|
|||
return {
|
||||
rules: [],
|
||||
aiRubricEnabled: false,
|
||||
aiCriteriaText: '',
|
||||
aiConfidenceThresholds: { high: 0.85, medium: 0.6, low: 0.4 },
|
||||
manualQueueEnabled: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
import { router, observerProcedure } from '../trpc'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
|
||||
const editionOrStageInput = z.object({
|
||||
stageId: z.string().optional(),
|
||||
|
|
@ -384,9 +385,16 @@ export const analyticsRouter = router({
|
|||
_count: { id: true },
|
||||
})
|
||||
|
||||
return distribution.map((d) => ({
|
||||
countryCode: d.country || 'UNKNOWN',
|
||||
count: d._count.id,
|
||||
// Resolve country names to ISO codes (DB may store "France" instead of "FR")
|
||||
const codeMap = new Map<string, number>()
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
requiredReviewsPerProject: requiredReviews,
|
||||
minAssignmentsPerJuror,
|
||||
maxAssignmentsPerJuror,
|
||||
jurorLimits: Object.keys(jurorLimits).length > 0 ? jurorLimits : undefined,
|
||||
existingAssignments: existingAssignments.map((a) => ({
|
||||
jurorId: a.userId,
|
||||
projectId: a.projectId,
|
||||
|
|
@ -420,8 +429,58 @@ export const assignmentRouter = router({
|
|||
})
|
||||
)
|
||||
.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({
|
||||
data: input.assignments.map((a) => ({
|
||||
data: allowedAssignments.map((a) => ({
|
||||
...a,
|
||||
stageId: input.stageId,
|
||||
method: 'BULK',
|
||||
|
|
@ -436,15 +495,19 @@ export const assignmentRouter = router({
|
|||
userId: ctx.user.id,
|
||||
action: 'BULK_CREATE',
|
||||
entityType: 'Assignment',
|
||||
detailsJson: { count: result.count },
|
||||
detailsJson: {
|
||||
count: result.count,
|
||||
requested: input.assignments.length,
|
||||
skippedDueToCapacity,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// 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
|
||||
const userAssignmentCounts = input.assignments.reduce(
|
||||
const userAssignmentCounts = allowedAssignments.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||
return acc
|
||||
|
|
@ -452,11 +515,6 @@ export const assignmentRouter = router({
|
|||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
select: { name: true, windowCloseAt: true },
|
||||
})
|
||||
|
||||
const deadline = stage?.windowCloseAt
|
||||
? new Date(stage.windowCloseAt).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
|
|
@ -495,6 +553,7 @@ export const assignmentRouter = router({
|
|||
created: result.count,
|
||||
requested: input.assignments.length,
|
||||
skipped: input.assignments.length - result.count,
|
||||
skippedDueToCapacity,
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -826,11 +885,61 @@ export const assignmentRouter = router({
|
|||
})
|
||||
),
|
||||
usedAI: z.boolean().default(false),
|
||||
forceOverride: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.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({
|
||||
data: input.assignments.map((a) => ({
|
||||
data: assignmentsToCreate.map((a) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
stageId: input.stageId,
|
||||
|
|
@ -852,13 +961,15 @@ export const assignmentRouter = router({
|
|||
stageId: input.stageId,
|
||||
count: created.count,
|
||||
usedAI: input.usedAI,
|
||||
forceOverride: input.forceOverride,
|
||||
skippedDueToCapacity,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (created.count > 0) {
|
||||
const userAssignmentCounts = input.assignments.reduce(
|
||||
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||
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(),
|
||||
})
|
||||
),
|
||||
forceOverride: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.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({
|
||||
data: input.assignments.map((a) => ({
|
||||
data: assignmentsToCreate.map((a) => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
stageId: input.stageId,
|
||||
|
|
@ -945,13 +1110,15 @@ export const assignmentRouter = router({
|
|||
detailsJson: {
|
||||
stageId: input.stageId,
|
||||
count: created.count,
|
||||
forceOverride: input.forceOverride,
|
||||
skippedDueToCapacity,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (created.count > 0) {
|
||||
const userAssignmentCounts = input.assignments.reduce(
|
||||
const userAssignmentCounts = assignmentsToCreate.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.userId] = (acc[a.userId] || 0) + 1
|
||||
return acc
|
||||
|
|
@ -998,7 +1165,11 @@ export const assignmentRouter = router({
|
|||
}
|
||||
}
|
||||
|
||||
return { created: created.count }
|
||||
return {
|
||||
created: created.count,
|
||||
requested: input.assignments.length,
|
||||
skippedDueToCapacity,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -696,6 +696,132 @@ export const fileRouter = router({
|
|||
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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ export const pipelineRouter = router({
|
|||
createStructure: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
programId: z.string(),
|
||||
programId: z.string().min(1, 'Program ID is required'),
|
||||
name: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
settingsJson: z.record(z.unknown()).optional(),
|
||||
|
|
|
|||
|
|
@ -267,22 +267,41 @@ export const stageAssignmentRouter = router({
|
|||
_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(
|
||||
(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(
|
||||
(sum, j) => sum + (j._count - input.targetPerJuror),
|
||||
0
|
||||
)
|
||||
const capacityTotal = underLoaded.reduce(
|
||||
(sum, j) => sum + (input.targetPerJuror - j._count),
|
||||
0
|
||||
)
|
||||
const capacityTotal = underLoaded.reduce((sum, j) => {
|
||||
const userMax = jurorMaxMap.get(j.userId)
|
||||
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)
|
||||
|
||||
if (input.dryRun) {
|
||||
|
|
@ -322,7 +341,12 @@ export const stageAssignmentRouter = router({
|
|||
for (const assignment of assignmentsToMove) {
|
||||
// Find an under-loaded juror who doesn't already have this project
|
||||
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
|
||||
const exists = await tx.assignment.findFirst({
|
||||
|
|
|
|||
|
|
@ -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": [...]}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type FileRequirementConfig = {
|
|||
export type FilterConfig = {
|
||||
rules: FilterRuleConfig[]
|
||||
aiRubricEnabled: boolean
|
||||
aiCriteriaText: string
|
||||
aiConfidenceThresholds: {
|
||||
high: number
|
||||
medium: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue