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:
283
src/app/(admin)/admin/rounds/[id]/filtering/page.tsx
Normal file
283
src/app/(admin)/admin/rounds/[id]/filtering/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
472
src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx
Normal file
472
src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
518
src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx
Normal file
518
src/app/(admin)/admin/rounds/[id]/filtering/rules/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user