Implement Prototype 1 improvements: unified members, project filters, audit expansion, filtering rounds, special awards

- Unified Member Management: merge /admin/users and /admin/mentors into /admin/members with role tabs, search, pagination
- Project List Filters: add search, multi-status filter, round/category/country selects, boolean toggles, URL persistence
- Audit Log Expansion: track logins, round state changes, evaluation submissions, file access, role changes via shared logAudit utility
- Founding Date Field: add foundedAt to Project model with CSV import support
- Filtering Round System: configurable rules (field-based, document check, AI screening), execution engine, results review with override/reinstate
- Special Awards System: named awards with eligibility criteria, dedicated jury, PICK_WINNER/RANKED/SCORED voting modes, AI eligibility
- Dashboard resilience: wrap heavy queries in try-catch to prevent error boundary on transient DB failures
- Reusable pagination component extracted to src/components/shared/pagination.tsx
- Old /admin/users and /admin/mentors routes redirect to /admin/members
- Prisma migration for all schema additions (additive, no data loss)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 16:58:29 +01:00
parent 8fda8deded
commit 90e3adfab2
44 changed files with 7268 additions and 2154 deletions

View File

@@ -0,0 +1,283 @@
'use client'
import { use } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Filter,
ListChecks,
ClipboardCheck,
Play,
Loader2,
CheckCircle2,
XCircle,
AlertTriangle,
} from 'lucide-react'
export default function FilteringDashboardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const { data: round, isLoading: roundLoading } =
trpc.round.get.useQuery({ id: roundId })
const { data: stats, isLoading: statsLoading, refetch: refetchStats } =
trpc.filtering.getResultStats.useQuery({ roundId })
const { data: rules } = trpc.filtering.getRules.useQuery({ roundId })
const executeRules = trpc.filtering.executeRules.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
const handleExecute = async () => {
try {
const result = await executeRules.mutateAsync({ roundId })
toast.success(
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
)
refetchStats()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to execute filtering'
)
}
}
const handleFinalize = async () => {
try {
const result = await finalizeResults.mutateAsync({ roundId })
toast.success(
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
)
refetchStats()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to finalize'
)
}
}
if (roundLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-40 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering {round?.name}
</h1>
<p className="text-muted-foreground">
Configure and run automated project screening
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleExecute}
disabled={
executeRules.isPending || !rules || rules.length === 0
}
>
{executeRules.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
Run Filtering
</Button>
</div>
</div>
{/* Stats Cards */}
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
) : stats && stats.total > 0 ? (
<div className="grid gap-4 sm:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Filter className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold">{stats.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{stats.passed}
</p>
<p className="text-sm text-muted-foreground">Passed</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/10">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-2xl font-bold text-red-600">
{stats.filteredOut}
</p>
<p className="text-sm text-muted-foreground">Filtered Out</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-2xl font-bold text-amber-600">
{stats.flagged}
</p>
<p className="text-sm text-muted-foreground">Flagged</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No filtering results yet</p>
<p className="text-sm text-muted-foreground">
Configure rules and run filtering to screen projects
</p>
</CardContent>
</Card>
)}
{/* Quick Links */}
<div className="grid gap-4 sm:grid-cols-2">
<Link href={`/admin/rounds/${roundId}/filtering/rules`}>
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListChecks className="h-5 w-5" />
Filtering Rules
</CardTitle>
<CardDescription>
Configure field-based, document, and AI screening rules
</CardDescription>
</CardHeader>
<CardContent>
<Badge variant="secondary">
{rules?.length || 0} rule{(rules?.length || 0) !== 1 ? 's' : ''}{' '}
configured
</Badge>
</CardContent>
</Card>
</Link>
<Link href={`/admin/rounds/${roundId}/filtering/results`}>
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ClipboardCheck className="h-5 w-5" />
Review Results
</CardTitle>
<CardDescription>
Review outcomes, override decisions, and finalize filtering
</CardDescription>
</CardHeader>
<CardContent>
{stats && stats.total > 0 ? (
<div className="flex gap-2">
<Badge variant="outline" className="text-green-600">
{stats.passed} passed
</Badge>
<Badge variant="outline" className="text-red-600">
{stats.filteredOut} filtered
</Badge>
<Badge variant="outline" className="text-amber-600">
{stats.flagged} flagged
</Badge>
</div>
) : (
<Badge variant="secondary">No results yet</Badge>
)}
</CardContent>
</Card>
</Link>
</div>
{/* Finalize */}
{stats && stats.total > 0 && (
<Card>
<CardHeader>
<CardTitle>Finalize Filtering</CardTitle>
<CardDescription>
Apply filtering outcomes to project statuses. Passed projects become
Eligible. Filtered-out projects are set aside (not deleted) and can
be reinstated at any time.
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={handleFinalize}
disabled={finalizeResults.isPending}
variant="default"
>
{finalizeResults.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Finalize Results
</Button>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,472 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Pagination } from '@/components/shared/pagination'
import { toast } from 'sonner'
import {
ArrowLeft,
CheckCircle2,
XCircle,
AlertTriangle,
ChevronDown,
RotateCcw,
Loader2,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const OUTCOME_BADGES: Record<
string,
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
> = {
PASSED: {
variant: 'default',
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
label: 'Passed',
},
FILTERED_OUT: {
variant: 'destructive',
icon: <XCircle className="mr-1 h-3 w-3" />,
label: 'Filtered Out',
},
FLAGGED: {
variant: 'secondary',
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
label: 'Flagged',
},
}
export default function FilteringResultsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [overrideDialog, setOverrideDialog] = useState<{
id: string
currentOutcome: string
} | null>(null)
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
const [overrideReason, setOverrideReason] = useState('')
const perPage = 20
const { data, isLoading, refetch } = trpc.filtering.getResults.useQuery({
roundId,
outcome: outcomeFilter
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
: undefined,
page,
perPage,
})
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const toggleRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
else next.add(id)
setExpandedRows(next)
}
const handleOverride = async () => {
if (!overrideDialog || !overrideReason.trim()) return
try {
await overrideResult.mutateAsync({
id: overrideDialog.id,
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
reason: overrideReason.trim(),
})
toast.success('Result overridden')
setOverrideDialog(null)
setOverrideReason('')
refetch()
} catch {
toast.error('Failed to override result')
}
}
const handleReinstate = async (projectId: string) => {
try {
await reinstateProject.mutateAsync({ roundId, projectId })
toast.success('Project reinstated')
refetch()
} catch {
toast.error('Failed to reinstate project')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-96 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}/filtering`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Filtering
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
</h1>
<p className="text-muted-foreground">
Review and override filtering outcomes
</p>
</div>
{/* Outcome Filter Tabs */}
<div className="flex gap-2">
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
<Button
key={outcome || 'all'}
variant={outcomeFilter === outcome ? 'default' : 'outline'}
size="sm"
onClick={() => {
setOutcomeFilter(outcome)
setPage(1)
}}
>
{outcome ? (
<>
{OUTCOME_BADGES[outcome].icon}
{OUTCOME_BADGES[outcome].label}
</>
) : (
'All'
)}
</Button>
))}
</div>
{/* Results Table */}
{data && data.results.length > 0 ? (
<>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Country</TableHead>
<TableHead>Outcome</TableHead>
<TableHead>Override</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.results.map((result) => {
const isExpanded = expandedRows.has(result.id)
const effectiveOutcome =
result.finalOutcome || result.outcome
const badge = OUTCOME_BADGES[effectiveOutcome]
return (
<>
<TableRow
key={result.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleRow(result.id)}
>
<TableCell>
<div>
<p className="font-medium">
{result.project.title}
</p>
<p className="text-sm text-muted-foreground">
{result.project.teamName}
</p>
</div>
</TableCell>
<TableCell>
{result.project.competitionCategory ? (
<Badge variant="outline">
{result.project.competitionCategory.replace(
'_',
' '
)}
</Badge>
) : (
'-'
)}
</TableCell>
<TableCell>
{result.project.country || '-'}
</TableCell>
<TableCell>
<Badge variant={badge?.variant || 'secondary'}>
{badge?.icon}
{badge?.label || effectiveOutcome}
</Badge>
</TableCell>
<TableCell>
{result.overriddenByUser ? (
<div className="text-xs">
<p className="font-medium">
{result.overriddenByUser.name || result.overriddenByUser.email}
</p>
<p className="text-muted-foreground">
{result.overrideReason}
</p>
</div>
) : (
'-'
)}
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOutcome('PASSED')
setOverrideDialog({
id: result.id,
currentOutcome: effectiveOutcome,
})
}}
>
<ShieldCheck className="mr-1 h-3 w-3" />
Override
</Button>
{effectiveOutcome === 'FILTERED_OUT' && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleReinstate(result.projectId)
}
disabled={reinstateProject.isPending}
>
<RotateCcw className="mr-1 h-3 w-3" />
Reinstate
</Button>
)}
</div>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${result.id}-detail`}>
<TableCell colSpan={6} className="bg-muted/30">
<div className="p-4 space-y-3">
<p className="text-sm font-medium">
Rule Results
</p>
{result.ruleResultsJson &&
Array.isArray(result.ruleResultsJson) ? (
<div className="space-y-2">
{(
result.ruleResultsJson as Array<{
ruleName: string
ruleType: string
passed: boolean
action: string
reasoning?: string
}>
).map((rr, i) => (
<div
key={i}
className="flex items-center gap-2 text-sm"
>
{rr.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
<span className="font-medium">
{rr.ruleName}
</span>
<Badge variant="outline" className="text-xs">
{rr.ruleType}
</Badge>
{rr.reasoning && (
<span className="text-muted-foreground">
{rr.reasoning}
</span>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No detailed rule results available
</p>
)}
{result.aiScreeningJson && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-sm font-medium">
AI Screening Details
<ChevronDown className="h-3 w-3" />
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(
result.aiScreeningJson,
null,
2
)}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</Card>
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
/>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No results found</p>
<p className="text-sm text-muted-foreground">
{outcomeFilter
? 'No results match this filter'
: 'Run filtering rules to generate results'}
</p>
</CardContent>
</Card>
)}
{/* Override Dialog */}
<Dialog
open={!!overrideDialog}
onOpenChange={(open) => {
if (!open) {
setOverrideDialog(null)
setOverrideReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Override Filtering Result</DialogTitle>
<DialogDescription>
Change the outcome for this project. This will be logged in the
audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New Outcome</Label>
<Select
value={overrideOutcome}
onValueChange={setOverrideOutcome}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASSED">Passed</SelectItem>
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
<SelectItem value="FLAGGED">Flagged</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Input
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="Explain why you're overriding..."
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOverrideDialog(null)}
>
Cancel
</Button>
<Button
onClick={handleOverride}
disabled={
overrideResult.isPending || !overrideReason.trim()
}
>
{overrideResult.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Override
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,518 @@
'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
Plus,
Trash2,
GripVertical,
Loader2,
FileCheck,
Brain,
Filter,
} from 'lucide-react'
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
const RULE_TYPE_LABELS: Record<RuleType, string> = {
FIELD_BASED: 'Field-Based',
DOCUMENT_CHECK: 'Document Check',
AI_SCREENING: 'AI Screening',
}
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
FIELD_BASED: <Filter className="h-4 w-4" />,
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
AI_SCREENING: <Brain className="h-4 w-4" />,
}
const FIELD_OPTIONS = [
{ value: 'competitionCategory', label: 'Competition Category' },
{ value: 'foundedAt', label: 'Founded Date' },
{ value: 'country', label: 'Country' },
{ value: 'geographicZone', label: 'Geographic Zone' },
{ value: 'tags', label: 'Tags' },
{ value: 'oceanIssue', label: 'Ocean Issue' },
]
const OPERATOR_OPTIONS = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'contains', label: 'Contains' },
{ value: 'in', label: 'In (list)' },
{ value: 'not_in', label: 'Not In (list)' },
{ value: 'is_empty', label: 'Is Empty' },
{ value: 'older_than_years', label: 'Older Than (years)' },
{ value: 'newer_than_years', label: 'Newer Than (years)' },
]
export default function FilteringRulesPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: roundId } = use(params)
const { data: rules, isLoading, refetch } =
trpc.filtering.getRules.useQuery({ roundId })
const createRule = trpc.filtering.createRule.useMutation()
const updateRule = trpc.filtering.updateRule.useMutation()
const deleteRule = trpc.filtering.deleteRule.useMutation()
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [newRuleName, setNewRuleName] = useState('')
const [newRuleType, setNewRuleType] = useState<RuleType>('FIELD_BASED')
// Field-based config state
const [conditionField, setConditionField] = useState('competitionCategory')
const [conditionOperator, setConditionOperator] = useState('equals')
const [conditionValue, setConditionValue] = useState('')
const [conditionLogic, setConditionLogic] = useState<'AND' | 'OR'>('AND')
const [conditionAction, setConditionAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
// Document check config state
const [minFileCount, setMinFileCount] = useState('1')
const [docAction, setDocAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
// AI screening config state
const [criteriaText, setCriteriaText] = useState('')
const handleCreateRule = async () => {
if (!newRuleName.trim()) return
let configJson: Record<string, unknown> = {}
if (newRuleType === 'FIELD_BASED') {
configJson = {
conditions: [
{
field: conditionField,
operator: conditionOperator,
value: conditionOperator === 'in' || conditionOperator === 'not_in'
? conditionValue.split(',').map((v) => v.trim())
: conditionOperator === 'older_than_years' ||
conditionOperator === 'newer_than_years' ||
conditionOperator === 'greater_than' ||
conditionOperator === 'less_than'
? Number(conditionValue)
: conditionValue,
},
],
logic: conditionLogic,
action: conditionAction,
}
} else if (newRuleType === 'DOCUMENT_CHECK') {
configJson = {
minFileCount: parseInt(minFileCount) || 1,
action: docAction,
}
} else if (newRuleType === 'AI_SCREENING') {
configJson = {
criteriaText,
action: 'FLAG',
}
}
try {
await createRule.mutateAsync({
roundId,
name: newRuleName.trim(),
ruleType: newRuleType,
configJson,
priority: (rules?.length || 0) + 1,
})
toast.success('Rule created')
setShowCreateDialog(false)
resetForm()
refetch()
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to create rule'
)
}
}
const handleToggleActive = async (ruleId: string, isActive: boolean) => {
try {
await updateRule.mutateAsync({ id: ruleId, isActive })
refetch()
} catch {
toast.error('Failed to update rule')
}
}
const handleDeleteRule = async (ruleId: string) => {
try {
await deleteRule.mutateAsync({ id: ruleId })
toast.success('Rule deleted')
refetch()
} catch {
toast.error('Failed to delete rule')
}
}
const resetForm = () => {
setNewRuleName('')
setNewRuleType('FIELD_BASED')
setConditionField('competitionCategory')
setConditionOperator('equals')
setConditionValue('')
setConditionLogic('AND')
setConditionAction('REJECT')
setMinFileCount('1')
setDocAction('REJECT')
setCriteriaText('')
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}/filtering`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Filtering
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Rules
</h1>
<p className="text-muted-foreground">
Rules are evaluated in order of priority
</p>
</div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Filtering Rule</DialogTitle>
<DialogDescription>
Define conditions that projects must meet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Rule Name</Label>
<Input
value={newRuleName}
onChange={(e) => setNewRuleName(e.target.value)}
placeholder="e.g., Startup age check"
/>
</div>
<div className="space-y-2">
<Label>Rule Type</Label>
<Select
value={newRuleType}
onValueChange={(v) => setNewRuleType(v as RuleType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FIELD_BASED">Field-Based</SelectItem>
<SelectItem value="DOCUMENT_CHECK">
Document Check
</SelectItem>
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
</SelectContent>
</Select>
</div>
{/* Field-Based Config */}
{newRuleType === 'FIELD_BASED' && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Field</Label>
<Select
value={conditionField}
onValueChange={setConditionField}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_OPTIONS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operator</Label>
<Select
value={conditionOperator}
onValueChange={setConditionOperator}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{conditionOperator !== 'is_empty' && (
<div className="space-y-2">
<Label>Value</Label>
<Input
value={conditionValue}
onChange={(e) => setConditionValue(e.target.value)}
placeholder={
conditionOperator === 'in' ||
conditionOperator === 'not_in'
? 'Comma-separated values'
: 'Value'
}
/>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Logic</Label>
<Select
value={conditionLogic}
onValueChange={(v) =>
setConditionLogic(v as 'AND' | 'OR')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Action</Label>
<Select
value={conditionAction}
onValueChange={(v) =>
setConditionAction(v as 'PASS' | 'REJECT' | 'FLAG')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PASS">Pass</SelectItem>
<SelectItem value="REJECT">Reject</SelectItem>
<SelectItem value="FLAG">Flag</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
{/* Document Check Config */}
{newRuleType === 'DOCUMENT_CHECK' && (
<>
<div className="space-y-2">
<Label>Minimum File Count</Label>
<Input
type="number"
min="1"
value={minFileCount}
onChange={(e) => setMinFileCount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Action if not met</Label>
<Select
value={docAction}
onValueChange={(v) =>
setDocAction(v as 'PASS' | 'REJECT' | 'FLAG')
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="REJECT">Reject</SelectItem>
<SelectItem value="FLAG">Flag</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{/* AI Screening Config */}
{newRuleType === 'AI_SCREENING' && (
<div className="space-y-2">
<Label>Screening Criteria</Label>
<Textarea
value={criteriaText}
onChange={(e) => setCriteriaText(e.target.value)}
placeholder="Describe the criteria for AI to evaluate projects against..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
AI screening always flags projects for human review, never
auto-rejects.
</p>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateRule}
disabled={
createRule.isPending ||
!newRuleName.trim() ||
(newRuleType === 'AI_SCREENING' && !criteriaText.trim())
}
>
{createRule.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Rules List */}
{rules && rules.length > 0 ? (
<div className="space-y-3">
{rules.map((rule, index) => (
<Card key={rule.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex items-center gap-2 text-muted-foreground">
<GripVertical className="h-4 w-4" />
<span className="text-sm font-mono w-6 text-center">
{index + 1}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{RULE_TYPE_ICONS[rule.ruleType as RuleType]}
<p className="font-medium">{rule.name}</p>
<Badge variant="outline">
{RULE_TYPE_LABELS[rule.ruleType as RuleType]}
</Badge>
{!rule.isActive && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{rule.ruleType === 'AI_SCREENING'
? (rule.configJson as Record<string, unknown>)
?.criteriaText
? String(
(rule.configJson as Record<string, unknown>)
.criteriaText
).slice(0, 80) + '...'
: 'AI screening rule'
: rule.ruleType === 'DOCUMENT_CHECK'
? `Min ${(rule.configJson as Record<string, unknown>)?.minFileCount || 1} file(s)`
: `${((rule.configJson as Record<string, unknown>)?.conditions as Array<Record<string, unknown>>)?.length || 0} condition(s)`}
</p>
</div>
<Switch
checked={rule.isActive}
onCheckedChange={(checked) =>
handleToggleActive(rule.id, checked)
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
disabled={deleteRule.isPending}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Filter className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rules configured</p>
<p className="text-sm text-muted-foreground">
Add filtering rules to screen projects automatically
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -30,6 +30,7 @@ import {
Pause,
BarChart3,
Upload,
Filter,
} from 'lucide-react'
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
@@ -344,6 +345,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Import Projects
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/filtering`}>
<Filter className="mr-2 h-4 w-4" />
Manage Filtering
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />