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:
686
src/app/(admin)/admin/onboarding/[id]/page.tsx
Normal file
686
src/app/(admin)/admin/onboarding/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
src/app/(admin)/admin/onboarding/new/page.tsx
Normal file
171
src/app/(admin)/admin/onboarding/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/app/(admin)/admin/onboarding/page.tsx
Normal file
153
src/app/(admin)/admin/onboarding/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
676
src/app/(public)/apply/[slug]/wizard/page.tsx
Normal file
676
src/app/(public)/apply/[slug]/wizard/page.tsx
Normal file
@@ -0,0 +1,676 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle, AlertCircle, Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
// Country list for country select special field
|
||||
const COUNTRIES = [
|
||||
'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Argentina', 'Armenia', 'Australia',
|
||||
'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium',
|
||||
'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei',
|
||||
'Bulgaria', 'Burkina Faso', 'Burundi', 'Cambodia', 'Cameroon', 'Canada', 'Cape Verde',
|
||||
'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo', 'Costa Rica',
|
||||
'Croatia', 'Cuba', 'Cyprus', 'Czech Republic', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic',
|
||||
'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini', 'Ethiopia',
|
||||
'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada',
|
||||
'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Honduras', 'Hungary', 'Iceland', 'India',
|
||||
'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan',
|
||||
'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia',
|
||||
'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives',
|
||||
'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova',
|
||||
'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar', 'Namibia', 'Nauru', 'Nepal',
|
||||
'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia',
|
||||
'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru',
|
||||
'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis',
|
||||
'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe',
|
||||
'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia',
|
||||
'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka',
|
||||
'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Taiwan', 'Tajikistan', 'Tanzania', 'Thailand',
|
||||
'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu',
|
||||
'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States', 'Uruguay', 'Uzbekistan',
|
||||
'Vanuatu', 'Vatican City', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',
|
||||
]
|
||||
|
||||
// Ocean issues for special field
|
||||
const OCEAN_ISSUES = [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Pollution Reduction' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Climate Mitigation' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology Innovation' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable Shipping' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue Carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Habitat Restoration' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Community Capacity Building' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable Fishing' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer Awareness' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Ocean Acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
// Competition categories for special field
|
||||
const COMPETITION_CATEGORIES = [
|
||||
{ value: 'STARTUP', label: 'Startup - Existing company with traction' },
|
||||
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept - Student/graduate project' },
|
||||
]
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
type FieldType = {
|
||||
id: string
|
||||
fieldType: string
|
||||
name: string
|
||||
label: string
|
||||
description?: string | null
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
minValue?: number | null
|
||||
maxValue?: number | null
|
||||
optionsJson: unknown
|
||||
conditionJson: unknown
|
||||
width: string
|
||||
specialType?: string | null
|
||||
projectMapping?: string | null
|
||||
}
|
||||
|
||||
type StepType = {
|
||||
id: string
|
||||
name: string
|
||||
title: string
|
||||
description?: string | null
|
||||
isOptional: boolean
|
||||
fields: FieldType[]
|
||||
}
|
||||
|
||||
export default function OnboardingWizardPage({ params }: PageProps) {
|
||||
const { slug } = use(params)
|
||||
const router = useRouter()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
|
||||
|
||||
// Fetch onboarding config
|
||||
const { data: config, isLoading, error } = trpc.onboarding.getConfig.useQuery(
|
||||
{ slug },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
// Form state
|
||||
const { control, handleSubmit, watch, setValue, formState: { errors }, trigger } = useForm({
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const watchedValues = watch()
|
||||
|
||||
// Submit mutation
|
||||
const submitMutation = trpc.onboarding.submit.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setSubmitted(true)
|
||||
setConfirmationMessage(result.confirmationMessage || null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Submission failed')
|
||||
},
|
||||
})
|
||||
|
||||
const steps = config?.steps || []
|
||||
const currentStep = steps[currentStepIndex]
|
||||
const isLastStep = currentStepIndex === steps.length - 1
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
||||
|
||||
// Navigate between steps
|
||||
const goToNextStep = async () => {
|
||||
// Validate current step fields
|
||||
const currentFields = currentStep?.fields || []
|
||||
const fieldNames = currentFields.map((f) => f.name)
|
||||
const isValid = await trigger(fieldNames)
|
||||
|
||||
if (!isValid) {
|
||||
toast.error('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
// Submit the form
|
||||
const allData = watchedValues
|
||||
await submitMutation.mutateAsync({
|
||||
formId: config!.form.id,
|
||||
contactName: allData.contactName || allData.name || '',
|
||||
contactEmail: allData.contactEmail || allData.email || '',
|
||||
contactPhone: allData.contactPhone || allData.phone,
|
||||
projectName: allData.projectName || allData.title || '',
|
||||
description: allData.description,
|
||||
competitionCategory: allData.competitionCategory,
|
||||
oceanIssue: allData.oceanIssue,
|
||||
country: allData.country,
|
||||
institution: allData.institution,
|
||||
teamName: allData.teamName,
|
||||
wantsMentorship: allData.wantsMentorship,
|
||||
referralSource: allData.referralSource,
|
||||
foundedAt: allData.foundedAt,
|
||||
teamMembers: allData.teamMembers,
|
||||
metadata: allData,
|
||||
gdprConsent: allData.gdprConsent || false,
|
||||
})
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrevStep = () => {
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
// Render field based on type and special type
|
||||
const renderField = (field: FieldType) => {
|
||||
const errorMessage = errors[field.name]?.message as string | undefined
|
||||
|
||||
// Handle special field types
|
||||
if (field.specialType) {
|
||||
switch (field.specialType) {
|
||||
case 'COMPETITION_CATEGORY':
|
||||
return (
|
||||
<div key={field.id} className="space-y-3">
|
||||
<Label>
|
||||
Competition Category
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a category' : false }}
|
||||
render={({ field: f }) => (
|
||||
<RadioGroup value={f.value} onValueChange={f.onChange} className="space-y-3">
|
||||
{COMPETITION_CATEGORIES.map((cat) => (
|
||||
<div
|
||||
key={cat.value}
|
||||
className={cn(
|
||||
'flex items-start space-x-3 p-4 rounded-lg border cursor-pointer transition-colors',
|
||||
f.value === cat.value ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={() => f.onChange(cat.value)}
|
||||
>
|
||||
<RadioGroupItem value={cat.value} id={cat.value} className="mt-0.5" />
|
||||
<Label htmlFor={cat.value} className="font-normal cursor-pointer">
|
||||
{cat.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'OCEAN_ISSUE':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
Ocean Issue Focus
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select an ocean issue' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select the primary ocean issue your project addresses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OCEAN_ISSUES.map((issue) => (
|
||||
<SelectItem key={issue.value} value={issue.value}>
|
||||
{issue.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'COUNTRY_SELECT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
{field.label || 'Country'}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a country' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRIES.map((country) => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'GDPR_CONSENT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg text-sm">
|
||||
<p className="font-medium mb-2">Terms & Conditions</p>
|
||||
<p className="text-muted-foreground">
|
||||
By submitting this application, you agree to our terms of service and privacy policy.
|
||||
Your data will be processed in accordance with GDPR regulations.
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => value === true || 'You must accept the terms and conditions'
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<Label htmlFor={field.name} className="font-normal leading-tight cursor-pointer">
|
||||
I accept the terms and conditions and consent to the processing of my data
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Standard field types
|
||||
switch (field.fieldType) {
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'PHONE':
|
||||
case 'URL':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
pattern: field.fieldType === 'EMAIL' ? { value: /^\S+@\S+$/i, message: 'Invalid email address' } : undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
|
||||
placeholder={field.placeholder || undefined}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'TEXTAREA':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
placeholder={field.placeholder || undefined}
|
||||
rows={4}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'SELECT':
|
||||
const options = (field.optionsJson as Array<{ value: string; label: string }>) || []
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'CHECKBOX':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: field.required
|
||||
? (value) => value === true || `${field.label} is required`
|
||||
: undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={field.name} className="font-normal cursor-pointer">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'DATE':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type="date"
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Application Not Available</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{error.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-full bg-green-100 p-3 mb-4">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Application Submitted!</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{confirmationMessage || 'Thank you for your submission. We will review your application and get back to you soon.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config || steps.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Form Not Configured</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
This application form has not been configured yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Step {currentStepIndex + 1} of {steps.length}</span>
|
||||
<span>{Math.round(progress)}% complete</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex justify-between mt-4">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
index < currentStepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: index === currentStepIndex
|
||||
? 'bg-primary text-primary-foreground ring-4 ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{index < currentStepIndex ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{currentStep?.title}</CardTitle>
|
||||
{currentStep?.description && (
|
||||
<CardDescription>{currentStep.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => { e.preventDefault(); goToNextStep(); }}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{currentStep?.fields.map((field) => (
|
||||
<div key={field.id} className={cn(field.width === 'half' ? '' : 'col-span-full')}>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goToPrevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={goToNextStep}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
Submit Application
|
||||
<Check className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-8">
|
||||
{config.program?.name} {config.program?.year && `${config.program.year}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { appRouter } from '@/server/routers/_app'
|
||||
import { createContext } from '@/server/context'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
// Allow long-running operations (AI filtering, bulk imports)
|
||||
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
|
||||
export const maxDuration = 300 // 5 minutes
|
||||
|
||||
const RATE_LIMIT = 100 // requests per window
|
||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||
|
||||
|
||||
Reference in New Issue
Block a user