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

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

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

View File

@@ -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>
)
}

View File

@@ -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">
<Label>Required Reviews per Project</Label>
<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">
<Label>Max Load per Juror</Label>
<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">
<Label>Min Load per Juror</Label>
<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>
<Label>Availability Weighting</Label>
<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">
<Label>Overflow Policy</Label>
<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) =>

View File

@@ -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">
<Label className="text-xs">Routing Mode</Label>
<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">
<Label className="text-xs">Decision Mode</Label>
<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">
<Label className="text-xs">Scoring Mode</Label>
<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) =>

View File

@@ -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">
<Label htmlFor="pipeline-slug">Slug</Label>
<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">
<Label htmlFor="pipeline-program">Program</Label>
<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 })}

View File

@@ -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>
<Label>AI Screening</Label>
<p className="text-xs text-muted-foreground">
Use AI to evaluate projects against rubric criteria
<div className="flex items-center gap-1.5">
<Brain className="h-4 w-4 text-primary" />
<Label>AI Screening</Label>
<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>
</div>
<div className="space-y-2">
<Label className="text-xs">Medium Confidence Threshold</Label>
<div className="flex items-center gap-3">
{/* 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>
<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>
<Label>Manual Review Queue</Label>
<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>
)
}

View File

@@ -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>
<Label>Submission Window</Label>
<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">
<Label>Late Submission Policy</Label>
<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">
<Label>Grace Period (hours)</Label>
<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">
<Label>File Requirements</Label>
<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"

View File

@@ -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>
<Label>Jury Voting</Label>
<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>
<Label>Audience Voting</Label>
<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">
<Label className="text-xs">Audience Vote Weight</Label>
<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">
<Label>Cohort Setup Mode</Label>
<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">
<Label>Result Reveal Policy</Label>
<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) =>

View File

@@ -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>
<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>
<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" />

View File

@@ -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">

View File

@@ -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">
<CardTitle className="text-sm">Validation Checks</CardTitle>
<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">
<CardTitle className="text-sm">Structure Summary</CardTitle>
<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">

View File

@@ -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>

View File

@@ -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"
>
<span className="truncate">{ps.project.title}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{ps.state}
</Badge>
</div>
<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>
)}

View File

@@ -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>