Add background filtering jobs, improved date picker, AI reasoning display
Build and Push Docker Image / build (push) Successful in 14m19s Details

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

34
package-lock.json generated
View File

@ -61,6 +61,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.54.2",
@ -268,6 +269,12 @@
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
"license": "MIT"
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -6343,6 +6350,12 @@
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@ -11638,6 +11651,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "9.13.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz",
"integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",

View File

@ -74,6 +74,7 @@
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.54.2",

View File

@ -0,0 +1,147 @@
-- Add Onboarding System Schema Changes
-- This migration adds the onboarding configuration system for the public application wizard
-- CreateEnum: SpecialFieldType
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'SpecialFieldType') THEN
CREATE TYPE "SpecialFieldType" AS ENUM (
'TEAM_MEMBERS',
'COMPETITION_CATEGORY',
'OCEAN_ISSUE',
'FILE_UPLOAD',
'GDPR_CONSENT',
'COUNTRY_SELECT'
);
END IF;
END $$;
-- CreateTable: OnboardingStep
CREATE TABLE IF NOT EXISTS "OnboardingStep" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isOptional" BOOLEAN NOT NULL DEFAULT false,
"conditionJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OnboardingStep_pkey" PRIMARY KEY ("id")
);
-- Add columns to ApplicationForm
DO $$
BEGIN
-- roundId column (unique)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationForm' AND column_name = 'roundId'
) THEN
ALTER TABLE "ApplicationForm" ADD COLUMN "roundId" TEXT;
END IF;
-- sendConfirmationEmail column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationForm' AND column_name = 'sendConfirmationEmail'
) THEN
ALTER TABLE "ApplicationForm" ADD COLUMN "sendConfirmationEmail" BOOLEAN NOT NULL DEFAULT true;
END IF;
-- sendTeamInviteEmails column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationForm' AND column_name = 'sendTeamInviteEmails'
) THEN
ALTER TABLE "ApplicationForm" ADD COLUMN "sendTeamInviteEmails" BOOLEAN NOT NULL DEFAULT true;
END IF;
-- confirmationEmailSubject column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationForm' AND column_name = 'confirmationEmailSubject'
) THEN
ALTER TABLE "ApplicationForm" ADD COLUMN "confirmationEmailSubject" TEXT;
END IF;
-- confirmationEmailBody column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationForm' AND column_name = 'confirmationEmailBody'
) THEN
ALTER TABLE "ApplicationForm" ADD COLUMN "confirmationEmailBody" TEXT;
END IF;
END $$;
-- Add columns to ApplicationFormField
DO $$
BEGIN
-- stepId column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationFormField' AND column_name = 'stepId'
) THEN
ALTER TABLE "ApplicationFormField" ADD COLUMN "stepId" TEXT;
END IF;
-- projectMapping column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationFormField' AND column_name = 'projectMapping'
) THEN
ALTER TABLE "ApplicationFormField" ADD COLUMN "projectMapping" TEXT;
END IF;
-- specialType column
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ApplicationFormField' AND column_name = 'specialType'
) THEN
ALTER TABLE "ApplicationFormField" ADD COLUMN "specialType" "SpecialFieldType";
END IF;
END $$;
-- Create indexes for OnboardingStep
CREATE INDEX IF NOT EXISTS "OnboardingStep_formId_idx" ON "OnboardingStep"("formId");
CREATE INDEX IF NOT EXISTS "OnboardingStep_sortOrder_idx" ON "OnboardingStep"("sortOrder");
-- Create index for ApplicationForm.roundId
CREATE UNIQUE INDEX IF NOT EXISTS "ApplicationForm_roundId_key" ON "ApplicationForm"("roundId");
CREATE INDEX IF NOT EXISTS "ApplicationForm_roundId_idx" ON "ApplicationForm"("roundId");
-- Create index for ApplicationFormField.stepId
CREATE INDEX IF NOT EXISTS "ApplicationFormField_stepId_idx" ON "ApplicationFormField"("stepId");
-- Add foreign key constraints
DO $$
BEGIN
-- OnboardingStep -> ApplicationForm
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'OnboardingStep_formId_fkey'
) THEN
ALTER TABLE "OnboardingStep" ADD CONSTRAINT "OnboardingStep_formId_fkey"
FOREIGN KEY ("formId") REFERENCES "ApplicationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
-- ApplicationFormField -> OnboardingStep
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'ApplicationFormField_stepId_fkey'
) THEN
ALTER TABLE "ApplicationFormField" ADD CONSTRAINT "ApplicationFormField_stepId_fkey"
FOREIGN KEY ("stepId") REFERENCES "OnboardingStep"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
-- ApplicationForm -> Round
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'ApplicationForm_roundId_fkey'
) THEN
ALTER TABLE "ApplicationForm" ADD CONSTRAINT "ApplicationForm_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;

View File

@ -0,0 +1,38 @@
-- Create FilteringJobStatus enum if not exists
DO $$ BEGIN
CREATE TYPE "FilteringJobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create FilteringJob table if not exists
CREATE TABLE IF NOT EXISTS "FilteringJob" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"status" "FilteringJobStatus" NOT NULL DEFAULT 'PENDING',
"totalProjects" INTEGER NOT NULL DEFAULT 0,
"totalBatches" INTEGER NOT NULL DEFAULT 0,
"currentBatch" INTEGER NOT NULL DEFAULT 0,
"processedCount" INTEGER NOT NULL DEFAULT 0,
"passedCount" INTEGER NOT NULL DEFAULT 0,
"filteredCount" INTEGER NOT NULL DEFAULT 0,
"flaggedCount" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FilteringJob_pkey" PRIMARY KEY ("id")
);
-- Add indexes
CREATE INDEX IF NOT EXISTS "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
CREATE INDEX IF NOT EXISTS "FilteringJob_status_idx" ON "FilteringJob"("status");
-- Add foreign key if not exists
DO $$ BEGIN
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -167,6 +167,15 @@ enum FormFieldType {
INSTRUCTIONS
}
enum SpecialFieldType {
TEAM_MEMBERS // Team member repeater
COMPETITION_CATEGORY // Business Concept vs Startup
OCEAN_ISSUE // Ocean issue dropdown
FILE_UPLOAD // File upload
GDPR_CONSENT // GDPR consent checkbox
COUNTRY_SELECT // Country dropdown
}
// =============================================================================
// APPLICANT SYSTEM ENUMS
// =============================================================================
@ -376,14 +385,16 @@ model Round {
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
roundProjects RoundProject[]
assignments Assignment[]
evaluationForms EvaluationForm[]
gracePeriods GracePeriod[]
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
roundProjects RoundProject[]
assignments Assignment[]
evaluationForms EvaluationForm[]
gracePeriods GracePeriod[]
liveVotingSession LiveVotingSession?
filteringRules FilteringRule[]
filteringResults FilteringResult[]
filteringRules FilteringRule[]
filteringResults FilteringResult[]
filteringJobs FilteringJob[]
applicationForm ApplicationForm?
@@index([programId])
@@index([status])
@ -858,22 +869,35 @@ model ApplicationForm {
confirmationMessage String? @db.Text
// Round linking (for onboarding forms that create projects)
roundId String? @unique
// Email settings
sendConfirmationEmail Boolean @default(true)
sendTeamInviteEmails Boolean @default(true)
confirmationEmailSubject String?
confirmationEmailBody String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
fields ApplicationFormField[]
steps OnboardingStep[]
submissions ApplicationFormSubmission[]
@@index([programId])
@@index([status])
@@index([isPublic])
@@index([roundId])
}
model ApplicationFormField {
id String @id @default(cuid())
formId String
stepId String? // Which step this field belongs to (for onboarding)
fieldType FormFieldType
name String // Internal name (e.g., "project_title")
label String // Display label (e.g., "Project Title")
@ -889,6 +913,10 @@ model ApplicationFormField {
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
// Onboarding-specific fields
projectMapping String? // Maps to Project column: "title", "description", etc.
specialType SpecialFieldType? // Special handling for complex fields
sortOrder Int @default(0)
width String @default("full") // full, half
@ -896,7 +924,30 @@ model ApplicationFormField {
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
step OnboardingStep? @relation(fields: [stepId], references: [id], onDelete: SetNull)
@@index([formId])
@@index([stepId])
@@index([sortOrder])
}
model OnboardingStep {
id String @id @default(cuid())
formId String
name String // Internal identifier (e.g., "category", "contact")
title String // Display title (e.g., "Category", "Contact Information")
description String? @db.Text
sortOrder Int @default(0)
isOptional Boolean @default(false)
conditionJson Json? @db.JsonB // Conditional visibility: { fieldId, operator, value }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
fields ApplicationFormField[]
@@index([formId])
@@index([sortOrder])
@ -1117,6 +1168,39 @@ model FilteringResult {
@@index([outcome])
}
// Tracks progress of long-running filtering jobs
model FilteringJob {
id String @id @default(cuid())
roundId String
status FilteringJobStatus @default(PENDING)
totalProjects Int @default(0)
totalBatches Int @default(0)
currentBatch Int @default(0)
processedCount Int @default(0)
passedCount Int @default(0)
filteredCount Int @default(0)
flaggedCount Int @default(0)
errorMessage String? @db.Text
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@index([roundId])
@@index([status])
}
enum FilteringJobStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
// =============================================================================
// SPECIAL AWARDS SYSTEM
// =============================================================================

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>

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

View File

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

View File

@ -91,8 +91,8 @@ const navigation = [
icon: Handshake,
},
{
name: 'Forms',
href: '/admin/forms' as const,
name: 'Onboarding',
href: '/admin/onboarding' as const,
icon: FileText,
},
]

View File

@ -0,0 +1,72 @@
'use client'
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
month_caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex',
weekday:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day_button: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
range_end: 'day-range-end',
selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
today: 'bg-accent text-accent-foreground',
outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
disabled: 'text-muted-foreground opacity-50',
range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
hidden: 'invisible',
...classNames,
}}
components={{
Chevron: ({ orientation }) =>
orientation === 'left' ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
),
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }

View File

@ -0,0 +1,192 @@
'use client'
import * as React from 'react'
import { format, setHours, setMinutes } from 'date-fns'
import { CalendarIcon, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
interface DateTimePickerProps {
value?: Date | null
onChange?: (date: Date | null) => void
placeholder?: string
disabled?: boolean
className?: string
/** If true, only shows date picker without time */
dateOnly?: boolean
/** If true, allows clearing the value */
clearable?: boolean
}
// Generate hour options (00-23)
const hours = Array.from({ length: 24 }, (_, i) => i)
// Generate minute options (00, 15, 30, 45) for easier selection
const minutes = [0, 15, 30, 45]
export function DateTimePicker({
value,
onChange,
placeholder = 'Select date and time',
disabled = false,
className,
dateOnly = false,
clearable = true,
}: DateTimePickerProps) {
const [open, setOpen] = React.useState(false)
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(
value ?? undefined
)
// Sync internal state with external value
React.useEffect(() => {
setSelectedDate(value ?? undefined)
}, [value])
const handleDateSelect = (date: Date | undefined) => {
if (!date) {
setSelectedDate(undefined)
onChange?.(null)
return
}
// Preserve time from previous selection or use noon as default
const newDate = selectedDate
? setHours(setMinutes(date, selectedDate.getMinutes()), selectedDate.getHours())
: setHours(setMinutes(date, 0), 12) // Default to noon
setSelectedDate(newDate)
onChange?.(newDate)
}
const handleTimeChange = (type: 'hour' | 'minute', valueStr: string) => {
if (!selectedDate) return
const numValue = parseInt(valueStr, 10)
let newDate: Date
if (type === 'hour') {
newDate = setHours(selectedDate, numValue)
} else {
newDate = setMinutes(selectedDate, numValue)
}
setSelectedDate(newDate)
onChange?.(newDate)
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
setSelectedDate(undefined)
onChange?.(null)
}
const formatDisplayDate = (date: Date) => {
if (dateOnly) {
return format(date, 'MMM d, yyyy')
}
return format(date, 'MMM d, yyyy HH:mm')
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className={cn(
'w-full justify-start text-left font-normal',
!selectedDate && 'text-muted-foreground',
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? formatDisplayDate(selectedDate) : placeholder}
{clearable && selectedDate && (
<span
role="button"
tabIndex={0}
className="ml-auto text-muted-foreground hover:text-foreground"
onClick={handleClear}
onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
>
×
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex flex-col">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
initialFocus
/>
{!dateOnly && (
<div className="border-t p-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Time:</span>
<Select
value={selectedDate ? String(selectedDate.getHours()) : undefined}
onValueChange={(v) => handleTimeChange('hour', v)}
disabled={!selectedDate}
>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
{hours.map((hour) => (
<SelectItem key={hour} value={String(hour)}>
{String(hour).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground">:</span>
<Select
value={selectedDate ? String(selectedDate.getMinutes()) : undefined}
onValueChange={(v) => handleTimeChange('minute', v)}
disabled={!selectedDate}
>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{minutes.map((minute) => (
<SelectItem key={minute} value={String(minute)}>
{String(minute).padStart(2, '0')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedDate && (
<p className="mt-2 text-xs text-muted-foreground">
Selected: {format(selectedDate, 'EEEE, MMMM d, yyyy')} at{' '}
{format(selectedDate, 'HH:mm')}
</p>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -470,6 +470,98 @@ Together for a healthier ocean.
}
}
/**
* Generate application confirmation email template
*/
function getApplicationConfirmationTemplate(
name: string,
projectName: string,
programName: string,
customMessage?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const customMessageHtml = customMessage
? `<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0; padding: 20px; background-color: ${BRAND.lightGray}; border-radius: 8px;">${customMessage.replace(/\n/g, '<br>')}</div>`
: ''
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for submitting your application to <strong style="color: ${BRAND.darkBlue};">${programName}</strong>!`)}
${infoBox(`Your project "<strong>${projectName}</strong>" has been successfully received.`, 'success')}
${customMessageHtml}
${paragraph('Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don\'t hesitate to reach out.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You will receive email updates about your application status.
</p>
`
return {
subject: `Application Received - ${projectName}`,
html: getEmailWrapper(content),
text: `
${greeting}
Thank you for submitting your application to ${programName}!
Your project "${projectName}" has been successfully received.
${customMessage || ''}
Our team will review your submission and get back to you soon. In the meantime, if you have any questions, please don't hesitate to reach out.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate team member invite email template
*/
function getTeamMemberInviteTemplate(
name: string,
projectName: string,
teamLeadName: string,
inviteUrl: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
const content = `
${sectionTitle(greeting)}
${paragraph(`<strong>${teamLeadName}</strong> has invited you to join their team for the project "<strong style="color: ${BRAND.darkBlue};">${projectName}</strong>" on the Monaco Ocean Protection Challenge platform.`)}
${paragraph('Click the button below to accept the invitation and set up your account.')}
${ctaButton(inviteUrl, 'Accept Invitation')}
${infoBox('This invitation link will expire in 30 days.', 'info')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
If you weren't expecting this invitation, you can safely ignore this email.
</p>
`
return {
subject: `You've been invited to join "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
${teamLeadName} has invited you to join their team for the project "${projectName}" on the Monaco Ocean Protection Challenge platform.
Click the link below to accept the invitation and set up your account:
${inviteUrl}
This invitation link will expire in 30 days.
If you weren't expecting this invitation, you can safely ignore this email.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
// =============================================================================
// Email Sending Functions
// =============================================================================
@ -634,3 +726,57 @@ export async function verifyEmailConnection(): Promise<boolean> {
return false
}
}
/**
* Send application confirmation email to applicant
*/
export async function sendApplicationConfirmationEmail(
email: string,
applicantName: string,
projectName: string,
programName: string,
customMessage?: string
): Promise<void> {
const template = getApplicationConfirmationTemplate(
applicantName,
projectName,
programName,
customMessage
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}
/**
* Send team member invite email
*/
export async function sendTeamMemberInviteEmail(
email: string,
memberName: string,
projectName: string,
teamLeadName: string,
inviteUrl: string
): Promise<void> {
const template = getTeamMemberInviteTemplate(
memberName,
projectName,
teamLeadName,
inviteUrl
)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}

View File

@ -16,6 +16,7 @@ import { partnerRouter } from './partner'
import { notionImportRouter } from './notion-import'
import { typeformImportRouter } from './typeform-import'
import { applicationFormRouter } from './applicationForm'
import { onboardingRouter } from './onboarding'
// Phase 2B routers
import { tagRouter } from './tag'
import { applicantRouter } from './applicant'
@ -51,6 +52,7 @@ export const appRouter = router({
notionImport: notionImportRouter,
typeformImport: typeformImportRouter,
applicationForm: applicationFormRouter,
onboarding: onboardingRouter,
// Phase 2B routers
tag: tagRouter,
applicant: applicantRouter,

View File

@ -28,6 +28,16 @@ const fieldTypeEnum = z.enum([
'INSTRUCTIONS',
])
// Special field type enum
const specialFieldTypeEnum = z.enum([
'TEAM_MEMBERS',
'COMPETITION_CATEGORY',
'OCEAN_ISSUE',
'FILE_UPLOAD',
'GDPR_CONSENT',
'COUNTRY_SELECT',
])
// Field input schema
const fieldInputSchema = z.object({
fieldType: fieldTypeEnum,
@ -52,6 +62,25 @@ const fieldInputSchema = z.object({
.optional(),
sortOrder: z.number().int().default(0),
width: z.enum(['full', 'half']).default('full'),
// Onboarding-specific fields
stepId: z.string().optional(),
projectMapping: z.string().optional(),
specialType: specialFieldTypeEnum.optional(),
})
// Step input schema
const stepInputSchema = z.object({
name: z.string().min(1).max(100),
title: z.string().min(1).max(255),
description: z.string().optional(),
isOptional: z.boolean().default(false),
conditionJson: z
.object({
fieldId: z.string(),
operator: z.enum(['equals', 'not_equals', 'contains', 'not_empty']),
value: z.string().optional(),
})
.optional(),
})
export const applicationFormRouter = router({
@ -101,7 +130,7 @@ export const applicationFormRouter = router({
}),
/**
* Get a single form by ID (admin view with all fields)
* Get a single form by ID (admin view with all fields and steps)
*/
get: adminProcedure
.input(z.object({ id: z.string() }))
@ -110,7 +139,12 @@ export const applicationFormRouter = router({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
round: { select: { id: true, name: true, slug: true } },
fields: { orderBy: { sortOrder: 'asc' } },
steps: {
orderBy: { sortOrder: 'asc' },
include: { fields: { orderBy: { sortOrder: 'asc' } } },
},
_count: { select: { submissions: true } },
},
})
@ -315,7 +349,7 @@ export const applicationFormRouter = router({
}),
/**
* Add a field to a form
* Add a field to a form (or step)
*/
addField: adminProcedure
.input(
@ -325,19 +359,28 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Get max sort order
// Get max sort order (within the step if specified, otherwise form-wide)
const whereClause = input.field.stepId
? { stepId: input.field.stepId }
: { formId: input.formId, stepId: null }
const maxOrder = await ctx.prisma.applicationFormField.aggregate({
where: { formId: input.formId },
where: whereClause,
_max: { sortOrder: true },
})
const { stepId, projectMapping, specialType, ...restField } = input.field
const field = await ctx.prisma.applicationFormField.create({
data: {
formId: input.formId,
...input.field,
sortOrder: input.field.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
...restField,
sortOrder: restField.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
optionsJson: restField.optionsJson ?? undefined,
conditionJson: restField.conditionJson ?? undefined,
stepId: stepId ?? undefined,
projectMapping: projectMapping ?? undefined,
specialType: specialType ?? undefined,
},
})
@ -355,12 +398,18 @@ export const applicationFormRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { stepId, projectMapping, specialType, ...restField } = input.field
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.id },
data: {
...input.field,
optionsJson: input.field.optionsJson ?? undefined,
conditionJson: input.field.conditionJson ?? undefined,
...restField,
optionsJson: restField.optionsJson ?? undefined,
conditionJson: restField.conditionJson ?? undefined,
// Handle nullable fields explicitly
stepId: stepId === undefined ? undefined : stepId,
projectMapping: projectMapping === undefined ? undefined : projectMapping,
specialType: specialType === undefined ? undefined : specialType,
},
})
@ -733,6 +782,248 @@ export const applicationFormRouter = router({
return newForm
}),
// ===========================================================================
// ONBOARDING STEP MANAGEMENT
// ===========================================================================
/**
* Create a new step in a form
*/
createStep: adminProcedure
.input(
z.object({
formId: z.string(),
step: stepInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
// Get max sort order
const maxOrder = await ctx.prisma.onboardingStep.aggregate({
where: { formId: input.formId },
_max: { sortOrder: true },
})
const step = await ctx.prisma.onboardingStep.create({
data: {
formId: input.formId,
...input.step,
sortOrder: (maxOrder._max.sortOrder ?? -1) + 1,
conditionJson: input.step.conditionJson ?? undefined,
},
})
return step
}),
/**
* Update a step
*/
updateStep: adminProcedure
.input(
z.object({
id: z.string(),
step: stepInputSchema.partial(),
})
)
.mutation(async ({ ctx, input }) => {
const step = await ctx.prisma.onboardingStep.update({
where: { id: input.id },
data: {
...input.step,
conditionJson: input.step.conditionJson ?? undefined,
},
})
return step
}),
/**
* Delete a step (fields will have stepId set to null)
*/
deleteStep: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.onboardingStep.delete({
where: { id: input.id },
})
}),
/**
* Reorder steps
*/
reorderSteps: adminProcedure
.input(
z.object({
formId: z.string(),
stepIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.stepIds.map((id, index) =>
ctx.prisma.onboardingStep.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
/**
* Move a field to a different step
*/
moveFieldToStep: adminProcedure
.input(
z.object({
fieldId: z.string(),
stepId: z.string().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const field = await ctx.prisma.applicationFormField.update({
where: { id: input.fieldId },
data: { stepId: input.stepId },
})
return field
}),
/**
* Update email settings for a form
*/
updateEmailSettings: adminProcedure
.input(
z.object({
formId: z.string(),
sendConfirmationEmail: z.boolean().optional(),
sendTeamInviteEmails: z.boolean().optional(),
confirmationEmailSubject: z.string().optional().nullable(),
confirmationEmailBody: z.string().optional().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const { formId, ...data } = input
const form = await ctx.prisma.applicationForm.update({
where: { id: formId },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_EMAIL_SETTINGS',
entityType: 'ApplicationForm',
entityId: formId,
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Link a form to a round (for onboarding forms that create projects)
*/
linkToRound: adminProcedure
.input(
z.object({
formId: z.string(),
roundId: z.string().nullable(),
})
)
.mutation(async ({ ctx, input }) => {
// Check if another form is already linked to this round
if (input.roundId) {
const existing = await ctx.prisma.applicationForm.findFirst({
where: {
roundId: input.roundId,
NOT: { id: input.formId },
},
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Another form is already linked to this round',
})
}
}
const form = await ctx.prisma.applicationForm.update({
where: { id: input.formId },
data: { roundId: input.roundId },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'LINK_TO_ROUND',
entityType: 'ApplicationForm',
entityId: input.formId,
detailsJson: { roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
}),
/**
* Get form with steps for onboarding builder
*/
getForBuilder: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.id },
include: {
program: { select: { id: true, name: true, year: true } },
round: { select: { id: true, name: true, slug: true, programId: true } },
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { submissions: true } },
},
})
}),
/**
* Get available rounds for linking
*/
getAvailableRounds: adminProcedure
.input(z.object({ programId: z.string().optional() }))
.query(async ({ ctx, input }) => {
// Get rounds that don't have a linked form yet
return ctx.prisma.round.findMany({
where: {
...(input.programId ? { programId: input.programId } : {}),
applicationForm: null,
},
select: {
id: true,
name: true,
slug: true,
status: true,
program: { select: { id: true, name: true, year: true } },
},
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
})
}),
/**
* Get form statistics
*/

View File

@ -1,10 +1,140 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { Prisma, PrismaClient } from '@prisma/client'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { executeFilteringRules } from '../services/ai-filtering'
import { executeFilteringRules, type ProgressCallback } from '../services/ai-filtering'
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
// Background job execution function
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
try {
// Update job to running
await prisma.filteringJob.update({
where: { id: jobId },
data: { status: 'RUNNING', startedAt: new Date() },
})
// Get rules
const rules = await prisma.filteringRule.findMany({
where: { roundId },
orderBy: { priority: 'asc' },
})
// Get projects
const roundProjectEntries = await prisma.roundProject.findMany({
where: { roundId },
include: {
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
// Calculate batch info
const BATCH_SIZE = 20
const totalBatches = Math.ceil(projects.length / BATCH_SIZE)
await prisma.filteringJob.update({
where: { id: jobId },
data: { totalProjects: projects.length, totalBatches },
})
// Progress callback
const onProgress: ProgressCallback = async (progress) => {
await prisma.filteringJob.update({
where: { id: jobId },
data: {
currentBatch: progress.currentBatch,
processedCount: progress.processedCount,
},
})
}
// Execute rules
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress)
// Count outcomes
const passedCount = results.filter((r) => r.outcome === 'PASSED').length
const filteredCount = results.filter((r) => r.outcome === 'FILTERED_OUT').length
const flaggedCount = results.filter((r) => r.outcome === 'FLAGGED').length
// Upsert results
await prisma.$transaction(
results.map((r) =>
prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId,
projectId: r.projectId,
},
},
create: {
roundId,
projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
update: {
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
)
)
// Mark job as completed
await prisma.filteringJob.update({
where: { id: jobId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
processedCount: projects.length,
passedCount,
filteredCount,
flaggedCount,
},
})
// Audit log
await logAudit({
userId,
action: 'UPDATE',
entityType: 'Round',
entityId: roundId,
detailsJson: {
action: 'EXECUTE_FILTERING',
jobId,
projectCount: projects.length,
passed: passedCount,
filteredOut: filteredCount,
flagged: flaggedCount,
},
})
} catch (error) {
console.error('[Filtering Job] Error:', error)
await prisma.filteringJob.update({
where: { id: jobId },
data: {
status: 'FAILED',
completedAt: new Date(),
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
})
}
}
export const filteringRouter = router({
/**
@ -168,7 +298,112 @@ export const filteringRouter = router({
}),
/**
* Execute all filtering rules against projects in a round
* Start a filtering job (runs in background)
*/
startJob: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Check if there's already a running job
const existingJob = await ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId, status: 'RUNNING' },
})
if (existingJob) {
throw new TRPCError({
code: 'CONFLICT',
message: 'A filtering job is already running for this round',
})
}
// Get rules
const rules = await ctx.prisma.filteringRule.findMany({
where: { roundId: input.roundId },
orderBy: { priority: 'asc' },
})
if (rules.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No filtering rules configured for this round',
})
}
// Check AI config if needed
const hasAIRules = rules.some((r) => r.ruleType === 'AI_SCREENING' && r.isActive)
if (hasAIRules) {
const aiConfigured = await isOpenAIConfigured()
if (!aiConfigured) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message:
'AI screening rules require OpenAI to be configured. Go to Settings → AI to configure your API key.',
})
}
const testResult = await testOpenAIConnection()
if (!testResult.success) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: `AI configuration error: ${testResult.error}. Go to Settings → AI to fix.`,
})
}
}
// Count projects
const projectCount = await ctx.prisma.roundProject.count({
where: { roundId: input.roundId },
})
if (projectCount === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No projects found in this round',
})
}
// Create job
const job = await ctx.prisma.filteringJob.create({
data: {
roundId: input.roundId,
status: 'PENDING',
totalProjects: projectCount,
},
})
// Start background execution (non-blocking)
setImmediate(() => {
runFilteringJob(job.id, input.roundId, ctx.user.id).catch(console.error)
})
return { jobId: job.id, message: 'Filtering job started' }
}),
/**
* Get current job status
*/
getJobStatus: protectedProcedure
.input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.filteringJob.findUnique({
where: { id: input.jobId },
})
if (!job) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Job not found' })
}
return job
}),
/**
* Get latest job for a round
*/
getLatestJob: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.filteringJob.findFirst({
where: { roundId: input.roundId },
orderBy: { createdAt: 'desc' },
})
}),
/**
* Execute all filtering rules against projects in a round (synchronous, legacy)
*/
executeRules: adminProcedure
.input(z.object({ roundId: z.string() }))

View File

@ -0,0 +1,398 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, publicProcedure } from '../trpc'
import { sendApplicationConfirmationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { nanoid } from 'nanoid'
// Team member input for submission
const teamMemberInputSchema = z.object({
name: z.string().min(1),
email: z.string().email().optional(),
title: z.string().optional(),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']).default('MEMBER'),
})
export const onboardingRouter = router({
/**
* Get onboarding form configuration for public wizard
* Returns form + steps + fields + program info
*/
getConfig: publicProcedure
.input(
z.object({
slug: z.string(), // Round slug or form publicSlug
})
)
.query(async ({ ctx, input }) => {
// Try to find by round slug first
let form = await ctx.prisma.applicationForm.findFirst({
where: {
round: { slug: input.slug },
status: 'PUBLISHED',
isPublic: true,
},
include: {
program: { select: { id: true, name: true, year: true } },
round: {
select: {
id: true,
name: true,
slug: true,
submissionStartDate: true,
submissionEndDate: true,
submissionDeadline: true,
},
},
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
},
})
// If not found by round slug, try form publicSlug
if (!form) {
form = await ctx.prisma.applicationForm.findFirst({
where: {
publicSlug: input.slug,
status: 'PUBLISHED',
isPublic: true,
},
include: {
program: { select: { id: true, name: true, year: true } },
round: {
select: {
id: true,
name: true,
slug: true,
submissionStartDate: true,
submissionEndDate: true,
submissionDeadline: true,
},
},
steps: {
orderBy: { sortOrder: 'asc' },
include: {
fields: { orderBy: { sortOrder: 'asc' } },
},
},
fields: {
where: { stepId: null },
orderBy: { sortOrder: 'asc' },
},
},
})
}
if (!form) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application form not found or not accepting submissions',
})
}
// Check submission window
const now = new Date()
const startDate = form.round?.submissionStartDate || form.opensAt
const endDate = form.round?.submissionEndDate || form.round?.submissionDeadline || form.closesAt
if (startDate && now < startDate) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Applications are not yet open',
})
}
if (endDate && now > endDate) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Applications have closed',
})
}
// Check submission limit
if (form.submissionLimit) {
const count = await ctx.prisma.applicationFormSubmission.count({
where: { formId: form.id },
})
if (count >= form.submissionLimit) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form has reached its submission limit',
})
}
}
return {
form: {
id: form.id,
name: form.name,
description: form.description,
confirmationMessage: form.confirmationMessage,
},
program: form.program,
round: form.round,
steps: form.steps,
orphanFields: form.fields, // Fields not assigned to any step
}
}),
/**
* Submit an application through the onboarding wizard
* Creates Project, TeamMembers, and sends emails
*/
submit: publicProcedure
.input(
z.object({
formId: z.string(),
// Contact info
contactName: z.string().min(1),
contactEmail: z.string().email(),
contactPhone: z.string().optional(),
// Project info
projectName: z.string().min(1),
description: z.string().optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
oceanIssue: z
.enum([
'POLLUTION_REDUCTION',
'CLIMATE_MITIGATION',
'TECHNOLOGY_INNOVATION',
'SUSTAINABLE_SHIPPING',
'BLUE_CARBON',
'HABITAT_RESTORATION',
'COMMUNITY_CAPACITY',
'SUSTAINABLE_FISHING',
'CONSUMER_AWARENESS',
'OCEAN_ACIDIFICATION',
'OTHER',
])
.optional(),
country: z.string().optional(),
institution: z.string().optional(),
teamName: z.string().optional(),
wantsMentorship: z.boolean().optional(),
referralSource: z.string().optional(),
foundedAt: z.string().datetime().optional(),
// Team members
teamMembers: z.array(teamMemberInputSchema).optional(),
// Additional metadata (unmapped fields)
metadata: z.record(z.unknown()).optional(),
// GDPR consent
gdprConsent: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
if (!input.gdprConsent) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'You must accept the terms and conditions to submit',
})
}
// Get form with round info
const form = await ctx.prisma.applicationForm.findUniqueOrThrow({
where: { id: input.formId },
include: {
round: true,
program: true,
},
})
// Verify form is accepting submissions
if (!form.isPublic || form.status !== 'PUBLISHED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This form is not accepting submissions',
})
}
// Check if we need a round/program for project creation
const programId = form.round?.programId || form.programId
if (!programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This form is not linked to a program',
})
}
// Create or find user for contact email
let contactUser = await ctx.prisma.user.findUnique({
where: { email: input.contactEmail },
})
if (!contactUser) {
contactUser = await ctx.prisma.user.create({
data: {
email: input.contactEmail,
name: input.contactName,
role: 'APPLICANT',
status: 'ACTIVE',
},
})
}
// Create project
const project = await ctx.prisma.project.create({
data: {
programId,
title: input.projectName,
description: input.description,
teamName: input.teamName || input.projectName,
competitionCategory: input.competitionCategory,
oceanIssue: input.oceanIssue,
country: input.country,
institution: input.institution,
wantsMentorship: input.wantsMentorship ?? false,
referralSource: input.referralSource,
foundedAt: input.foundedAt ? new Date(input.foundedAt) : null,
submissionSource: 'PUBLIC_FORM',
submittedByEmail: input.contactEmail,
submittedByUserId: contactUser.id,
submittedAt: new Date(),
metadataJson: input.metadata as Prisma.InputJsonValue ?? {},
},
})
// Create RoundProject entry if form is linked to a round
if (form.roundId) {
await ctx.prisma.roundProject.create({
data: {
roundId: form.roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
}
// Create TeamMember for contact as LEAD
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: contactUser.id,
role: 'LEAD',
title: 'Team Lead',
},
})
// Process additional team members
const invitePromises: Promise<void>[] = []
if (input.teamMembers && input.teamMembers.length > 0) {
for (const member of input.teamMembers) {
// Skip if same email as contact
if (member.email === input.contactEmail) continue
let memberUser = member.email
? await ctx.prisma.user.findUnique({ where: { email: member.email } })
: null
if (member.email && !memberUser) {
// Create user with invite token
const inviteToken = nanoid(32)
const inviteTokenExpiresAt = new Date()
inviteTokenExpiresAt.setDate(inviteTokenExpiresAt.getDate() + 30) // 30 days
memberUser = await ctx.prisma.user.create({
data: {
email: member.email,
name: member.name,
role: 'APPLICANT',
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt,
},
})
// Queue invite email
if (form.sendTeamInviteEmails) {
const inviteUrl = `${process.env.NEXTAUTH_URL || ''}/accept-invite?token=${inviteToken}`
invitePromises.push(
sendTeamMemberInviteEmail(
member.email,
member.name,
input.projectName,
input.contactName,
inviteUrl
).catch((err) => {
console.error(`Failed to send invite email to ${member.email}:`, err)
})
)
}
}
// Create team member if we have a user
if (memberUser) {
await ctx.prisma.teamMember.create({
data: {
projectId: project.id,
userId: memberUser.id,
role: member.role,
title: member.title,
},
})
}
}
}
// Create form submission record
await ctx.prisma.applicationFormSubmission.create({
data: {
formId: input.formId,
email: input.contactEmail,
name: input.contactName,
dataJson: input as unknown as Prisma.InputJsonValue,
status: 'SUBMITTED',
},
})
// Send confirmation email
if (form.sendConfirmationEmail) {
const programName = form.program?.name || form.round?.name || 'the program'
try {
await sendApplicationConfirmationEmail(
input.contactEmail,
input.contactName,
input.projectName,
programName,
form.confirmationEmailBody || form.confirmationMessage || undefined
)
} catch (err) {
console.error('Failed to send confirmation email:', err)
}
}
// Wait for invite emails (don't block on failure)
await Promise.allSettled(invitePromises)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: contactUser.id,
action: 'SUBMIT_APPLICATION',
entityType: 'Project',
entityId: project.id,
detailsJson: {
formId: input.formId,
projectName: input.projectName,
teamMemberCount: (input.teamMembers?.length || 0) + 1,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
success: true,
projectId: project.id,
confirmationMessage: form.confirmationMessage,
}
}),
})

View File

@ -22,7 +22,16 @@ import {
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
import type { Prisma, FileType, SubmissionSource } from '@prisma/client'
import type { Prisma, FileType, SubmissionSource, PrismaClient } from '@prisma/client'
// ─── Progress Callback Type ─────────────────────────────────────────────────
export type ProgressCallback = (progress: {
currentBatch: number
totalBatches: number
processedCount: number
tokensUsed?: number
}) => Promise<void>
// ─── Types ──────────────────────────────────────────────────────────────────
@ -410,7 +419,8 @@ export async function executeAIScreening(
config: AIScreeningConfig,
projects: ProjectForFiltering[],
userId?: string,
entityId?: string
entityId?: string,
onProgress?: ProgressCallback
): Promise<Map<string, AIScreeningResult>> {
const results = new Map<string, AIScreeningResult>()
@ -444,13 +454,15 @@ export async function executeAIScreening(
}
let totalTokens = 0
const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE)
// Process in batches
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
const batchMappings = mappings.slice(i, i + BATCH_SIZE)
const currentBatch = Math.floor(i / BATCH_SIZE) + 1
console.log(`[AI Filtering] Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(anonymized.length / BATCH_SIZE)}`)
console.log(`[AI Filtering] Processing batch ${currentBatch}/${totalBatches}`)
const { results: batchResults, tokensUsed } = await processAIBatch(
openai,
@ -468,6 +480,16 @@ export async function executeAIScreening(
for (const [id, result] of batchResults) {
results.set(id, result)
}
// Report progress
if (onProgress) {
await onProgress({
currentBatch,
totalBatches,
processedCount: Math.min((currentBatch) * BATCH_SIZE, anonymized.length),
tokensUsed: totalTokens,
})
}
}
console.log(`[AI Filtering] Completed. Total tokens: ${totalTokens}`)
@ -513,7 +535,8 @@ export async function executeFilteringRules(
rules: FilteringRuleInput[],
projects: ProjectForFiltering[],
userId?: string,
roundId?: string
roundId?: string,
onProgress?: ProgressCallback
): Promise<ProjectFilteringResult[]> {
const activeRules = rules
.filter((r) => r.isActive)
@ -528,7 +551,7 @@ export async function executeFilteringRules(
for (const aiRule of aiRules) {
const config = aiRule.configJson as unknown as AIScreeningConfig
const screeningResults = await executeAIScreening(config, projects, userId, roundId)
const screeningResults = await executeAIScreening(config, projects, userId, roundId, onProgress)
aiResults.set(aiRule.id, screeningResults)
}