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:
2026-02-03 19:48:41 +01:00
parent 8be740a4fb
commit e2782b2b19
24 changed files with 3692 additions and 443 deletions

View File

@@ -0,0 +1,686 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Loader2,
Save,
Plus,
GripVertical,
Trash2,
Settings,
Eye,
Mail,
Link2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Sortable step item component
function SortableStep({
step,
isSelected,
onSelect,
onDelete,
}: {
step: { id: string; name: string; title: string; fields: unknown[] }
isSelected: boolean
onSelect: () => void
onDelete: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors',
isSelected ? 'border-primary bg-primary/5' : 'border-transparent hover:bg-muted',
isDragging && 'opacity-50'
)}
onClick={onSelect}
>
<button
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{step.title}</p>
<p className="text-xs text-muted-foreground">
{(step.fields as unknown[]).length} fields
</p>
</div>
<button
className="p-1 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
function OnboardingFormEditor({ formId }: { formId: string }) {
const router = useRouter()
const utils = trpc.useUtils()
// Fetch form data with steps
const { data: form, isLoading } = trpc.applicationForm.getForBuilder.useQuery(
{ id: formId },
{ refetchOnWindowFocus: false }
)
// Local state for editing
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Mutations
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Form updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const createStep = trpc.applicationForm.createStep.useMutation({
onSuccess: (data) => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(data.id)
toast.success('Step created')
},
})
const updateStep = trpc.applicationForm.updateStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Step updated')
},
})
const deleteStep = trpc.applicationForm.deleteStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(null)
toast.success('Step deleted')
},
})
const reorderSteps = trpc.applicationForm.reorderSteps.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
},
})
const updateEmailSettings = trpc.applicationForm.updateEmailSettings.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Email settings updated')
},
})
const linkToRound = trpc.applicationForm.linkToRound.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Round linked')
},
})
// Fetch available rounds
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery({
programId: form?.programId || undefined,
})
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleStepDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id || !form) return
const oldIndex = form.steps.findIndex((s) => s.id === active.id)
const newIndex = form.steps.findIndex((s) => s.id === over.id)
const newOrder = arrayMove(form.steps, oldIndex, newIndex)
reorderSteps.mutate({
formId,
stepIds: newOrder.map((s) => s.id),
})
}
const handleAddStep = () => {
const stepNumber = (form?.steps.length || 0) + 1
createStep.mutate({
formId,
step: {
name: `step_${stepNumber}`,
title: `Step ${stepNumber}`,
},
})
}
const selectedStep = form?.steps.find((s) => s.id === selectedStepId)
if (isLoading) {
return <FormEditorSkeleton />
}
if (!form) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Form not found</p>
<Link href="/admin/onboarding">
<Button variant="outline" className="mt-4">
Back to Onboarding
</Button>
</Link>
</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/onboarding">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{form.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={form.status === 'PUBLISHED' ? 'default' : 'secondary'}>
{form.status}
</Badge>
{form.round && (
<Badge variant="outline">
<Link2 className="mr-1 h-3 w-3" />
{form.round.name}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a href={`/apply/${form.publicSlug}`} target="_blank" rel="noopener noreferrer">
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList>
<TabsTrigger value="steps">Steps & Fields</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="emails">Emails</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Steps List */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-base">Steps</CardTitle>
<CardDescription>
Drag to reorder wizard steps
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={form.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{form.steps.map((step) => (
<SortableStep
key={step.id}
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => setSelectedStepId(step.id)}
onDelete={() => {
if (confirm('Delete this step? Fields will be unassigned.')) {
deleteStep.mutate({ id: step.id })
}
}}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={handleAddStep}
disabled={createStep.isPending}
>
{createStep.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add Step
</Button>
</CardContent>
</Card>
{/* Step Editor */}
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'}
</CardTitle>
</CardHeader>
<CardContent>
{selectedStep ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Step Title</Label>
<Input
value={selectedStep.title}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { title: e.target.value },
})
}}
/>
</div>
<div className="space-y-2">
<Label>Internal Name</Label>
<Input
value={selectedStep.name}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { name: e.target.value },
})
}}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Textarea
value={selectedStep.description || ''}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { description: e.target.value },
})
}}
rows={3}
/>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Fields in this step</h4>
{selectedStep.fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
No fields yet. Use the existing form editor to add fields.
</p>
) : (
<div className="space-y-2">
{selectedStep.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</p>
</div>
</div>
))}
</div>
)}
<Link href={`/admin/forms/${formId}`}>
<Button variant="outline" size="sm" className="mt-4">
Edit Fields in Form Editor
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
Select a step from the list to edit it
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Name</Label>
<Input
value={form.name}
onChange={(e) => {
updateForm.mutate({ id: formId, name: e.target.value })
}}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, description: e.target.value || null })
}}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
value={form.publicSlug || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, publicSlug: e.target.value || null })
}}
placeholder="your-form-slug"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4">
<div>
<Label>Public Access</Label>
<p className="text-sm text-muted-foreground">
Allow public submissions to this form
</p>
</div>
<Switch
checked={form.isPublic}
onCheckedChange={(checked) => {
updateForm.mutate({ id: formId, isPublic: checked })
}}
/>
</div>
<div className="space-y-2 pt-4">
<Label>Status</Label>
<Select
value={form.status}
onValueChange={(value) => {
updateForm.mutate({ id: formId, status: value as 'DRAFT' | 'PUBLISHED' | 'CLOSED' })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Round Linking</CardTitle>
<CardDescription>
Link this form to a round to create projects on submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Linked Round</Label>
<Select
value={form.roundId || 'none'}
onValueChange={(value) => {
linkToRound.mutate({
formId,
roundId: value === 'none' ? null : value,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No round linked</SelectItem>
{form.round && (
<SelectItem value={form.round.id}>
{form.round.name} (current)
</SelectItem>
)}
{availableRounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program?.name} {round.program?.year} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Emails Tab */}
<TabsContent value="emails" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Email Notifications</CardTitle>
<CardDescription>
Configure emails sent when applications are submitted
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Confirmation Email</Label>
<p className="text-sm text-muted-foreground">
Send a confirmation email to the applicant
</p>
</div>
<Switch
checked={form.sendConfirmationEmail}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendConfirmationEmail: checked,
})
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Team Invite Emails</Label>
<p className="text-sm text-muted-foreground">
Send invite emails to team members
</p>
</div>
<Switch
checked={form.sendTeamInviteEmails}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendTeamInviteEmails: checked,
})
}}
/>
</div>
{form.sendConfirmationEmail && (
<>
<div className="space-y-2 pt-4 border-t">
<Label>Custom Email Subject (optional)</Label>
<Input
value={form.confirmationEmailSubject || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailSubject: e.target.value || null,
})
}}
placeholder="Application Received - {projectName}"
/>
</div>
<div className="space-y-2">
<Label>Custom Email Message (optional)</Label>
<Textarea
value={form.confirmationEmailBody || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailBody: e.target.value || null,
})
}}
placeholder="Add a custom message to include in the confirmation email..."
rows={4}
/>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function FormEditorSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-24" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
</div>
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-96" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
export default function OnboardingFormPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<FormEditorSkeleton />}>
<OnboardingFormEditor formId={id} />
</Suspense>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewOnboardingFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
// Fetch programs for selection
const { data: programs } = trpc.program.list.useQuery({})
// Fetch available rounds for the selected program
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery(
{ programId: selectedProgramId || undefined },
{ enabled: true }
)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Onboarding form created successfully')
router.push(`/admin/onboarding/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: selectedProgramId || null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/onboarding">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Onboarding Form</h1>
<p className="text-muted-foreground">
Set up a new application wizard for project submissions
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Configure the basic settings for your onboarding form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., MOPC 2026 Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Edition / Program</Label>
<Select
value={selectedProgramId}
onValueChange={setSelectedProgramId}
>
<SelectTrigger>
<SelectValue placeholder="Select a program (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No program</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Link to a specific edition to enable project creation
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this application form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., mopc-2026"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/onboarding">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Link2,
} from 'lucide-react'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function OnboardingFormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No onboarding forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application wizard to accept project submissions
</p>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Onboarding Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
{form.program && (
<Badge variant="outline">{form.program.name} {form.program.year}</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/onboarding/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function OnboardingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Onboarding</h1>
<p className="text-muted-foreground">
Configure application wizards for project submissions
</p>
</div>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<OnboardingFormsList />
</Suspense>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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