Consolidated round management, AI filtering enhancements, MinIO storage restructure
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s

- Fix STAGE_ACTIVE bug in assignment router (now ROUND_ACTIVE)
- Add evaluation form CRUD (getForm + upsertForm endpoints)
- Add advanceProjects mutation for manual project advancement
- Rewrite round detail page: 7-tab consolidated interface
- Add filtering rules UI with full CRUD (field-based, document check, AI screening)
- Add pageCount field to ProjectFile for document page limit filtering
- Enhance AI filtering: per-file page limits, category/region-aware guidelines
- Restructure MinIO paths: {ProjectName}/{RoundName}/{timestamp}-{file}
- Update dashboard and pool page links from /admin/competitions to /admin/rounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 09:20:02 +01:00
parent 845554fdb8
commit 8e5fc18da6
14 changed files with 2606 additions and 303 deletions

View File

@@ -37,6 +37,13 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Play,
Loader2,
@@ -55,6 +62,13 @@ import {
RotateCcw,
Search,
ExternalLink,
Plus,
Pencil,
Trash2,
FileText,
Brain,
ListFilter,
GripVertical,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
@@ -385,6 +399,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
)}
</Card>
{/* Filtering Rules */}
<FilteringRulesSection roundId={roundId} />
{/* Stats Cards */}
{statsLoading ? (
<div className="grid gap-4 grid-cols-2 lg:grid-cols-5">
@@ -922,3 +939,738 @@ function ConfidenceIndicator({ value }: { value: number }) {
</span>
)
}
// ─── Filtering Rules Section ────────────────────────────────────────────────
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
}
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category', operators: ['equals', 'not_equals'] },
{ value: 'foundedAt', label: 'Founded Date', operators: ['older_than_years', 'newer_than_years'] },
{ value: 'country', label: 'Country', operators: ['equals', 'not_equals', 'in', 'not_in'] },
{ value: 'geographicZone', label: 'Geographic Zone', operators: ['equals', 'not_equals', 'contains'] },
{ value: 'tags', label: 'Tags', operators: ['contains', 'in'] },
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['equals', 'not_equals', 'in'] },
]
const FILE_TYPES = [
{ value: 'EXEC_SUMMARY', label: 'Executive Summary' },
{ value: 'PRESENTATION', label: 'Presentation' },
{ value: 'BUSINESS_PLAN', label: 'Business Plan' },
{ value: 'VIDEO', label: 'Video' },
{ value: 'VIDEO_PITCH', label: 'Video Pitch' },
{ value: 'SUPPORTING_DOC', label: 'Supporting Doc' },
{ value: 'OTHER', label: 'Other' },
]
type FieldCondition = {
field: string
operator: string
value: string | number | string[]
}
type RuleFormData = {
name: string
ruleType: RuleType
priority: number
// FIELD_BASED
conditions: FieldCondition[]
logic: 'AND' | 'OR'
fieldAction: 'PASS' | 'REJECT' | 'FLAG'
// DOCUMENT_CHECK
requiredFileTypes: string[]
minFileCount: number | ''
maxPages: number | ''
maxPagesByFileType: Record<string, number>
docAction: 'PASS' | 'REJECT' | 'FLAG'
// AI_SCREENING
criteriaText: string
aiAction: 'PASS' | 'REJECT' | 'FLAG'
batchSize: number
parallelBatches: number
}
const DEFAULT_FORM: RuleFormData = {
name: '',
ruleType: 'FIELD_BASED',
priority: 0,
conditions: [{ field: 'competitionCategory', operator: 'equals', value: '' }],
logic: 'AND',
fieldAction: 'REJECT',
requiredFileTypes: [],
minFileCount: '',
maxPages: '',
maxPagesByFileType: {},
docAction: 'REJECT',
criteriaText: '',
aiAction: 'FLAG',
batchSize: 20,
parallelBatches: 1,
}
function buildConfigJson(form: RuleFormData): Record<string, unknown> {
switch (form.ruleType) {
case 'FIELD_BASED':
return {
conditions: form.conditions.map((c) => ({
field: c.field,
operator: c.operator,
value: c.value,
})),
logic: form.logic,
action: form.fieldAction,
}
case 'DOCUMENT_CHECK': {
const config: Record<string, unknown> = {
action: form.docAction,
}
if (form.requiredFileTypes.length > 0) config.requiredFileTypes = form.requiredFileTypes
if (form.minFileCount !== '' && form.minFileCount > 0) config.minFileCount = form.minFileCount
if (form.maxPages !== '' && form.maxPages > 0) config.maxPages = form.maxPages
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
return config
}
case 'AI_SCREENING':
return {
criteriaText: form.criteriaText,
action: form.aiAction,
batchSize: form.batchSize,
parallelBatches: form.parallelBatches,
}
}
}
function parseConfigToForm(rule: { name: string; ruleType: string; configJson: unknown; priority: number }): RuleFormData {
const config = (rule.configJson || {}) as Record<string, unknown>
const base = { ...DEFAULT_FORM, name: rule.name, ruleType: rule.ruleType as RuleType, priority: rule.priority }
switch (rule.ruleType) {
case 'FIELD_BASED':
return {
...base,
conditions: (config.conditions as FieldCondition[]) || [{ field: 'competitionCategory', operator: 'equals', value: '' }],
logic: (config.logic as 'AND' | 'OR') || 'AND',
fieldAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
}
case 'DOCUMENT_CHECK':
return {
...base,
requiredFileTypes: (config.requiredFileTypes as string[]) || [],
minFileCount: (config.minFileCount as number) || '',
maxPages: (config.maxPages as number) || '',
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
}
case 'AI_SCREENING':
return {
...base,
criteriaText: (config.criteriaText as string) || '',
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
batchSize: (config.batchSize as number) || 20,
parallelBatches: (config.parallelBatches as number) || 1,
}
default:
return base
}
}
function FilteringRulesSection({ roundId }: { roundId: string }) {
const [isOpen, setIsOpen] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingRule, setEditingRule] = useState<string | null>(null)
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const utils = trpc.useUtils()
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery({ roundId })
const createMutation = trpc.filtering.createRule.useMutation({
onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId })
setDialogOpen(false)
setForm({ ...DEFAULT_FORM })
toast.success('Rule created')
},
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.filtering.updateRule.useMutation({
onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId })
setDialogOpen(false)
setEditingRule(null)
setForm({ ...DEFAULT_FORM })
toast.success('Rule updated')
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = trpc.filtering.deleteRule.useMutation({
onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId })
setDeleteConfirmId(null)
toast.success('Rule deleted')
},
onError: (err) => toast.error(err.message),
})
const toggleActiveMutation = trpc.filtering.updateRule.useMutation({
onSuccess: () => {
utils.filtering.getRules.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const handleSave = () => {
const configJson = buildConfigJson(form)
if (editingRule) {
updateMutation.mutate({ id: editingRule, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
} else {
createMutation.mutate({ roundId, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
}
}
const openEdit = (rule: any) => {
setEditingRule(rule.id)
setForm(parseConfigToForm(rule))
setDialogOpen(true)
}
const openCreate = () => {
setEditingRule(null)
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
setDialogOpen(true)
}
const meta = RULE_TYPE_META[form.ruleType]
return (
<>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ListFilter className="h-5 w-5 text-[#053d57]" />
<div>
<CardTitle className="text-base">Filtering Rules</CardTitle>
<CardDescription>
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} &mdash; executed in priority order
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); openCreate() }}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Rule
</Button>
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="pt-0">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : rules && rules.length > 0 ? (
<div className="space-y-2">
{rules.map((rule: any, idx: number) => {
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
const Icon = typeMeta.icon
const config = (rule.configJson || {}) as Record<string, unknown>
return (
<div
key={rule.id}
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
>
<div className="flex items-center gap-1 text-muted-foreground">
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
</div>
<Badge variant="outline" className={`text-xs shrink-0 ${typeMeta.color}`}>
<Icon className="h-3 w-3 mr-1" />
{typeMeta.label}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{rule.name}</p>
<p className="text-xs text-muted-foreground truncate">
{rule.ruleType === 'FIELD_BASED' && (
<>
{((config.conditions as any[]) || []).length} condition{((config.conditions as any[]) || []).length !== 1 ? 's' : ''} ({config.logic as string || 'AND'}) &rarr; {config.action as string}
</>
)}
{rule.ruleType === 'DOCUMENT_CHECK' && (
<>
{config.minFileCount ? `Min ${config.minFileCount} files` : ''}
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
? ` \u00b7 Page limits per type`
: ''}
{' \u2192 '}{config.action as string}
</>
)}
{rule.ruleType === 'AI_SCREENING' && (
<>
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} &rarr; {config.action as string}
</>
)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch
checked={rule.isActive}
onCheckedChange={(checked) => {
toggleActiveMutation.mutate({ id: rule.id, isActive: checked })
}}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(rule)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setDeleteConfirmId(rule.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">No filtering rules configured</p>
<p className="text-xs text-muted-foreground mt-1 mb-3">
Add rules to define how projects are screened
</p>
<Button variant="outline" size="sm" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add First Rule
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Create/Edit Rule Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open)
if (!open) { setEditingRule(null); setForm({ ...DEFAULT_FORM }) }
}}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle>
<DialogDescription>
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{/* Rule Name + Priority */}
<div className="grid grid-cols-[1fr_100px] gap-3">
<div>
<Label className="text-sm font-medium mb-1.5 block">Rule Name</Label>
<Input
placeholder="e.g. Startup Age Check"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<Label className="text-sm font-medium mb-1.5 block">Priority</Label>
<Input
type="number"
min={0}
value={form.priority}
onChange={(e) => setForm((f) => ({ ...f, priority: parseInt(e.target.value) || 0 }))}
/>
</div>
</div>
{/* Rule Type Selector */}
<div>
<Label className="text-sm font-medium mb-2 block">Rule Type</Label>
<div className="grid grid-cols-3 gap-2">
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
const Icon = m.icon
const selected = form.ruleType === type
return (
<button
key={type}
type="button"
className={`flex flex-col items-start gap-1.5 rounded-lg border p-3 text-left transition-all ${
selected ? 'border-[#053d57] bg-[#053d57]/5 ring-1 ring-[#053d57]/20' : 'hover:border-muted-foreground/30'
}`}
onClick={() => setForm((f) => ({ ...f, ruleType: type }))}
>
<div className="flex items-center gap-1.5">
<Icon className={`h-4 w-4 ${selected ? 'text-[#053d57]' : 'text-muted-foreground'}`} />
<span className={`text-sm font-medium ${selected ? 'text-[#053d57]' : ''}`}>{m.label}</span>
</div>
<p className="text-xs text-muted-foreground leading-snug">{m.description}</p>
</button>
)
})}
</div>
</div>
{/* Type-Specific Config */}
{form.ruleType === 'FIELD_BASED' && (
<div className="space-y-3 rounded-lg border p-4 bg-muted/20">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Conditions</Label>
<div className="flex items-center gap-2">
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
<SelectTrigger className="w-20 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{form.conditions.map((cond, i) => {
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
return (
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_36px] gap-2 items-end">
<div>
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Field</Label>}
<Select
value={cond.field}
onValueChange={(v) => {
const newConds = [...form.conditions]
const newFieldMeta = FIELD_OPTIONS.find((f) => f.value === v)
newConds[i] = { field: v, operator: newFieldMeta?.operators[0] || 'equals', value: '' }
setForm((f) => ({ ...f, conditions: newConds }))
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((fo) => (
<SelectItem key={fo.value} value={fo.value}>{fo.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Operator</Label>}
<Select
value={cond.operator}
onValueChange={(v) => {
const newConds = [...form.conditions]
newConds[i] = { ...newConds[i], operator: v }
setForm((f) => ({ ...f, conditions: newConds }))
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(fieldMeta?.operators || ['equals']).map((op) => (
<SelectItem key={op} value={op}>{op.replace(/_/g, ' ')}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Value</Label>}
<Input
className="h-8 text-xs"
placeholder="Value..."
value={typeof cond.value === 'object' ? (cond.value as string[]).join(', ') : String(cond.value)}
onChange={(e) => {
const newConds = [...form.conditions]
const val = ['in', 'not_in'].includes(cond.operator)
? e.target.value.split(',').map((s) => s.trim())
: ['older_than_years', 'newer_than_years'].includes(cond.operator)
? parseInt(e.target.value) || 0
: e.target.value
newConds[i] = { ...newConds[i], value: val }
setForm((f) => ({ ...f, conditions: newConds }))
}}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500"
disabled={form.conditions.length <= 1}
onClick={() => {
const newConds = form.conditions.filter((_, j) => j !== i)
setForm((f) => ({ ...f, conditions: newConds }))
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
})}
<Button
variant="outline"
size="sm"
onClick={() => setForm((f) => ({
...f,
conditions: [...f.conditions, { field: 'competitionCategory', operator: 'equals', value: '' }],
}))}
>
<Plus className="h-3 w-3 mr-1" />
Add Condition
</Button>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action when conditions match</Label>
<Select value={form.fieldAction} onValueChange={(v) => setForm((f) => ({ ...f, fieldAction: v as any }))}>
<SelectTrigger className="w-40 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASS">Pass (keep project)</SelectItem>
<SelectItem value="REJECT">Reject (filter out)</SelectItem>
<SelectItem value="FLAG">Flag (manual review)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{form.ruleType === 'DOCUMENT_CHECK' && (
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Minimum File Count</Label>
<Input
type="number"
min={0}
className="h-8 text-xs"
placeholder="e.g. 3"
value={form.minFileCount}
onChange={(e) => setForm((f) => ({ ...f, minFileCount: e.target.value ? parseInt(e.target.value) : '' }))}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Max Pages (any file)</Label>
<Input
type="number"
min={0}
className="h-8 text-xs"
placeholder="e.g. 10"
value={form.maxPages}
onChange={(e) => setForm((f) => ({ ...f, maxPages: e.target.value ? parseInt(e.target.value) : '' }))}
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Required File Formats</Label>
<div className="flex flex-wrap gap-2">
{['pdf', 'docx', 'pptx', 'mp4', 'xlsx'].map((ext) => (
<label key={ext} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox
checked={form.requiredFileTypes.includes(ext)}
onCheckedChange={(checked) => {
setForm((f) => ({
...f,
requiredFileTypes: checked
? [...f.requiredFileTypes, ext]
: f.requiredFileTypes.filter((t) => t !== ext),
}))
}}
/>
<span className="text-xs font-mono">.{ext}</span>
</label>
))}
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Max Pages by File Type</Label>
<div className="space-y-2">
{FILE_TYPES.map((ft) => {
const limit = form.maxPagesByFileType[ft.value]
const hasLimit = limit !== undefined
return (
<div key={ft.value} className="flex items-center gap-2">
<Checkbox
checked={hasLimit}
onCheckedChange={(checked) => {
setForm((f) => {
const next = { ...f.maxPagesByFileType }
if (checked) next[ft.value] = 10
else delete next[ft.value]
return { ...f, maxPagesByFileType: next }
})
}}
/>
<span className="text-xs w-32">{ft.label}</span>
{hasLimit && (
<Input
type="number"
min={1}
className="h-7 text-xs w-20"
value={limit}
onChange={(e) => {
setForm((f) => ({
...f,
maxPagesByFileType: { ...f.maxPagesByFileType, [ft.value]: parseInt(e.target.value) || 1 },
}))
}}
/>
)}
{hasLimit && <span className="text-xs text-muted-foreground">pages max</span>}
</div>
)
})}
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action when check fails</Label>
<Select value={form.docAction} onValueChange={(v) => setForm((f) => ({ ...f, docAction: v as any }))}>
<SelectTrigger className="w-40 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="REJECT">Reject (filter out)</SelectItem>
<SelectItem value="FLAG">Flag (manual review)</SelectItem>
<SelectItem value="PASS">Pass (informational)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{form.ruleType === 'AI_SCREENING' && (
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
<Textarea
placeholder="Write the criteria the AI should evaluate against. Example:&#10;&#10;1. Ocean conservation impact must be clearly stated&#10;2. Documents must be in English&#10;3. For Business Concepts, academic rigor is acceptable&#10;4. For African projects, apply a lower quality threshold (score >= 5/10)"
value={form.criteriaText}
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
rows={8}
className="text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size), and team size.
</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FLAG">Flag for review</SelectItem>
<SelectItem value="REJECT">Auto-reject</SelectItem>
<SelectItem value="PASS">Auto-pass</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
<Input
type="number"
min={1}
max={50}
className="h-8 text-xs"
value={form.batchSize}
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
<Input
type="number"
min={1}
max={10}
className="h-8 text-xs"
value={form.parallelBatches}
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
/>
</div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setDialogOpen(false); setEditingRule(null); setForm({ ...DEFAULT_FORM }) }}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!form.name.trim() || createMutation.isPending || updateMutation.isPending}
>
{(createMutation.isPending || updateMutation.isPending) && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingRule ? 'Update Rule' : 'Create Rule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteConfirmId} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Filtering Rule?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this rule. Projects already filtered will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => {
if (deleteConfirmId) deleteMutation.mutate({ id: deleteConfirmId })
}}
>
{deleteMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}