Add background filtering jobs, improved date picker, AI reasoning display
- Implement background job system for AI filtering to avoid HTTP timeouts - Add FilteringJob model to track progress of long-running filtering operations - Add real-time progress polling for filtering operations on round details page - Create custom DateTimePicker component with calendar popup (no year picker hassle) - Fix round date persistence bug (refetchOnWindowFocus was resetting form state) - Integrate filtering controls into round details page for filtering rounds - Display AI reasoning for flagged/filtered projects in results table - Add onboarding system scaffolding (schema, routes, basic UI) - Allow setting round dates in the past for manual overrides Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -43,13 +43,13 @@ const updateRoundSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
requiredReviews: z.number().int().min(1).max(10),
|
||||
votingStartAt: z.string().optional(),
|
||||
votingEndAt: z.string().optional(),
|
||||
votingStartAt: z.date().nullable().optional(),
|
||||
votingEndAt: z.date().nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.votingStartAt && data.votingEndAt) {
|
||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
||||
return data.votingEndAt > data.votingStartAt
|
||||
}
|
||||
return true
|
||||
},
|
||||
@@ -61,25 +61,19 @@ const updateRoundSchema = z
|
||||
|
||||
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
|
||||
|
||||
// Convert ISO date to datetime-local format
|
||||
function toDatetimeLocal(date: Date | string | null | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return format(d, "yyyy-MM-dd'T'HH:mm")
|
||||
}
|
||||
|
||||
function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
const router = useRouter()
|
||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
|
||||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
|
||||
// Fetch round data
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
|
||||
id: roundId,
|
||||
})
|
||||
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
|
||||
{ id: roundId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
// Fetch evaluation form
|
||||
const { data: evaluationForm, isLoading: loadingForm } =
|
||||
@@ -110,25 +104,26 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
defaultValues: {
|
||||
name: '',
|
||||
requiredReviews: 3,
|
||||
votingStartAt: '',
|
||||
votingEndAt: '',
|
||||
votingStartAt: null,
|
||||
votingEndAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Update form when round data loads
|
||||
// Update form when round data loads - only initialize once
|
||||
useEffect(() => {
|
||||
if (round) {
|
||||
if (round && !formInitialized) {
|
||||
form.reset({
|
||||
name: round.name,
|
||||
requiredReviews: round.requiredReviews,
|
||||
votingStartAt: toDatetimeLocal(round.votingStartAt),
|
||||
votingEndAt: toDatetimeLocal(round.votingEndAt),
|
||||
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
|
||||
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
|
||||
})
|
||||
// Set round type and settings
|
||||
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
|
||||
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
||||
setFormInitialized(true)
|
||||
}
|
||||
}, [round, form])
|
||||
}, [round, form, formInitialized])
|
||||
|
||||
// Initialize criteria from evaluation form
|
||||
useEffect(() => {
|
||||
@@ -151,8 +146,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
requiredReviews: data.requiredReviews,
|
||||
roundType,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
@@ -303,7 +298,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
<FormItem>
|
||||
<FormLabel>Start Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select start date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -317,7 +316,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
<FormItem>
|
||||
<FormLabel>End Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select end date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -326,7 +329,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to disable the voting window enforcement.
|
||||
Leave empty to disable the voting window enforcement. Past dates are allowed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,286 +1,24 @@
|
||||
'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'
|
||||
import { use, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Redirect to round details page - filtering is now integrated there
|
||||
export default function FilteringDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: roundId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
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 utils = trpc.useUtils()
|
||||
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()
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
router.replace(`/admin/rounds/${roundId}`)
|
||||
}, [router, roundId])
|
||||
|
||||
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 className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Redirecting to round details...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -159,9 +159,9 @@ export default function FilteringResultsPage({
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}/filtering`}>
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Filtering
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -208,9 +208,8 @@ export default function FilteringResultsPage({
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Outcome</TableHead>
|
||||
<TableHead>Override</TableHead>
|
||||
<TableHead className="w-[300px]">AI Reason</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -221,6 +220,17 @@ export default function FilteringResultsPage({
|
||||
result.finalOutcome || result.outcome
|
||||
const badge = OUTCOME_BADGES[effectiveOutcome]
|
||||
|
||||
// Extract AI reasoning from aiScreeningJson
|
||||
const aiScreening = result.aiScreeningJson as Record<string, {
|
||||
meetsCriteria?: boolean
|
||||
confidence?: number
|
||||
reasoning?: string
|
||||
qualityScore?: number
|
||||
spamRisk?: boolean
|
||||
}> | null
|
||||
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
|
||||
const aiReasoning = firstAiResult?.reasoning
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
@@ -235,6 +245,7 @@ export default function FilteringResultsPage({
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.project.teamName}
|
||||
{result.project.country && ` · ${result.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -251,26 +262,42 @@ export default function FilteringResultsPage({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{result.project.country || '-'}
|
||||
<div className="space-y-1">
|
||||
<Badge variant={badge?.variant || 'secondary'}>
|
||||
{badge?.icon}
|
||||
{badge?.label || effectiveOutcome}
|
||||
</Badge>
|
||||
{result.overriddenByUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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}
|
||||
{aiReasoning ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm line-clamp-2">
|
||||
{aiReasoning}
|
||||
</p>
|
||||
{firstAiResult && (
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
{firstAiResult.confidence !== undefined && (
|
||||
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
|
||||
)}
|
||||
{firstAiResult.qualityScore !== undefined && (
|
||||
<span>Quality: {firstAiResult.qualityScore}/10</span>
|
||||
)}
|
||||
{firstAiResult.spamRisk && (
|
||||
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
No AI screening
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -310,67 +337,121 @@ export default function FilteringResultsPage({
|
||||
</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
|
||||
<TableCell colSpan={5} className="bg-muted/30">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Rule Results */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
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-start gap-2 text-sm"
|
||||
>
|
||||
{rr.passed ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{rr.ruleName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{rr.ruleType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{rr.reasoning && (
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{rr.reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No detailed rule results available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Screening Details */}
|
||||
{aiScreening && Object.keys(aiScreening).length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
AI Screening Analysis
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(aiScreening).map(([ruleId, screening]) => (
|
||||
<div key={ruleId} className="p-3 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{screening.meetsCriteria ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
|
||||
</span>
|
||||
{screening.spamRisk && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Spam Risk
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{screening.reasoning && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{screening.reasoning}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{screening.confidence !== undefined && (
|
||||
<span>
|
||||
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
|
||||
</span>
|
||||
)}
|
||||
{screening.qualityScore !== undefined && (
|
||||
<span>
|
||||
Quality Score: <strong>{screening.qualityScore}/10</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Override Info */}
|
||||
{result.overriddenByUser && (
|
||||
<div className="pt-3 border-t">
|
||||
<p className="text-sm font-medium mb-1">Manual Override</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
||||
{result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
</p>
|
||||
{result.overrideReason && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Reason: {result.overrideReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
Edit,
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
@@ -56,7 +55,7 @@ import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
||||
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -67,14 +66,15 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
|
||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
|
||||
const isFilteringRound = roundType === 'FILTERING'
|
||||
// Check if this is a filtering round - roundType is stored directly on the round
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const { data: filteringStats, refetch: refetchFilteringStats } =
|
||||
trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
@@ -88,6 +88,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
|
||||
// Poll for job status when there's an active job
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
{ jobId: activeJobId! },
|
||||
{
|
||||
enabled: !!activeJobId,
|
||||
refetchInterval: activeJobId ? 2000 : false,
|
||||
}
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
@@ -108,19 +122,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
})
|
||||
|
||||
// Filtering mutations
|
||||
const executeRules = trpc.filtering.executeRules.useMutation()
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
const handleExecuteFiltering = async () => {
|
||||
try {
|
||||
const result = await executeRules.mutateAsync({ roundId })
|
||||
// Set active job from latest job on load
|
||||
useEffect(() => {
|
||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||
setActiveJobId(latestJob.id)
|
||||
}
|
||||
}, [latestJob])
|
||||
|
||||
// Handle job completion
|
||||
useEffect(() => {
|
||||
if (jobStatus?.status === 'COMPLETED') {
|
||||
toast.success(
|
||||
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
|
||||
`Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered out, ${jobStatus.flaggedCount} flagged`
|
||||
)
|
||||
setActiveJobId(null)
|
||||
refetchFilteringStats()
|
||||
refetchLatestJob()
|
||||
} else if (jobStatus?.status === 'FAILED') {
|
||||
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
||||
setActiveJobId(null)
|
||||
refetchLatestJob()
|
||||
}
|
||||
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
|
||||
|
||||
const handleStartFiltering = async () => {
|
||||
try {
|
||||
const result = await startJob.mutateAsync({ roundId })
|
||||
setActiveJobId(result.jobId)
|
||||
toast.info('Filtering job started. Progress will update automatically.')
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to execute filtering'
|
||||
error instanceof Error ? error.message : 'Failed to start filtering'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +176,11 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
||||
const progressPercent = jobStatus?.totalBatches
|
||||
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
||||
: 0
|
||||
|
||||
if (isLoading) {
|
||||
return <RoundDetailSkeleton />
|
||||
}
|
||||
@@ -475,20 +515,54 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExecuteFiltering}
|
||||
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
|
||||
onClick={handleStartFiltering}
|
||||
disabled={startJob.isPending || isJobRunning || !filteringRules || filteringRules.length === 0}
|
||||
>
|
||||
{executeRules.isPending ? (
|
||||
{startJob.isPending || isJobRunning ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Filtering
|
||||
{isJobRunning ? 'Running...' : 'Run Filtering'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress Card (when job is running) */}
|
||||
{isJobRunning && jobStatus && (
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
AI Filtering in Progress
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-blue-300 text-blue-700">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-blue-700 dark:text-blue-300">
|
||||
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
|
||||
</span>
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Status Warning */}
|
||||
{aiStatus?.hasAIRules && !aiStatus?.configured && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
@@ -551,7 +625,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : !isJobRunning && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||
@@ -581,7 +655,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
{filteringStats && filteringStats.total > 0 && (
|
||||
<Button
|
||||
onClick={handleFinalizeFiltering}
|
||||
disabled={finalizeResults.isPending}
|
||||
disabled={finalizeResults.isPending || isJobRunning}
|
||||
variant="default"
|
||||
>
|
||||
{finalizeResults.isPending ? (
|
||||
@@ -644,14 +718,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
{!isFilteringRound && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtering
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -35,16 +35,17 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
programId: z.string().min(1, 'Please select a program'),
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
requiredReviews: z.number().int().min(1).max(10),
|
||||
votingStartAt: z.string().optional(),
|
||||
votingEndAt: z.string().optional(),
|
||||
votingStartAt: z.date().nullable().optional(),
|
||||
votingEndAt: z.date().nullable().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.votingStartAt && data.votingEndAt) {
|
||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
||||
return data.votingEndAt > data.votingStartAt
|
||||
}
|
||||
return true
|
||||
}, {
|
||||
@@ -75,8 +76,8 @@ function CreateRoundContent() {
|
||||
programId: programIdParam || '',
|
||||
name: '',
|
||||
requiredReviews: 3,
|
||||
votingStartAt: '',
|
||||
votingEndAt: '',
|
||||
votingStartAt: null,
|
||||
votingEndAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,8 +88,8 @@ function CreateRoundContent() {
|
||||
roundType,
|
||||
requiredReviews: data.requiredReviews,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,7 +247,11 @@ function CreateRoundContent() {
|
||||
<FormItem>
|
||||
<FormLabel>Start Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select start date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -260,7 +265,11 @@ function CreateRoundContent() {
|
||||
<FormItem>
|
||||
<FormLabel>End Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select end date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -269,8 +278,7 @@ function CreateRoundContent() {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to set the voting window later. The round will need to be
|
||||
activated before jury members can submit evaluations.
|
||||
Leave empty to set the voting window later. Past dates are allowed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user