Add background filtering jobs, improved date picker, AI reasoning display
Build and Push Docker Image / build (push) Successful in 14m19s
Details
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:
parent
8be740a4fb
commit
e2782b2b19
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
@ -384,6 +393,8 @@ model Round {
|
|||
liveVotingSession LiveVotingSession?
|
||||
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
|
||||
|
||||
|
|
@ -897,6 +925,29 @@ model ApplicationFormField {
|
|||
|
||||
// Relations
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/trpc/server'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
Inbox,
|
||||
Link2,
|
||||
} from 'lucide-react'
|
||||
|
||||
const statusColors = {
|
||||
DRAFT: 'bg-gray-100 text-gray-800',
|
||||
PUBLISHED: 'bg-green-100 text-green-800',
|
||||
CLOSED: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
async function OnboardingFormsList() {
|
||||
const caller = await api()
|
||||
const { data: forms } = await caller.applicationForm.list({
|
||||
perPage: 50,
|
||||
})
|
||||
|
||||
if (forms.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No onboarding forms yet</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first application wizard to accept project submissions
|
||||
</p>
|
||||
<Link href="/admin/onboarding/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Onboarding Form
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{forms.map((form) => (
|
||||
<Card key={form.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{form.name}</h3>
|
||||
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
|
||||
{form.status}
|
||||
</Badge>
|
||||
{form.program && (
|
||||
<Badge variant="outline">{form.program.name} {form.program.year}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
|
||||
<span>{form._count.fields} fields</span>
|
||||
<span>-</span>
|
||||
<span>{form._count.submissions} submissions</span>
|
||||
{form.publicSlug && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span className="text-primary">/apply/{form.publicSlug}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{form.publicSlug && form.status === 'PUBLISHED' && (
|
||||
<a
|
||||
href={`/apply/${form.publicSlug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="ghost" size="icon" title="View Public Form">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<Link href={`/admin/forms/${form.id}/submissions`}>
|
||||
<Button variant="ghost" size="icon" title="View Submissions">
|
||||
<Inbox className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/onboarding/${form.id}`}>
|
||||
<Button variant="ghost" size="icon" title="Edit Form">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Onboarding</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure application wizards for project submissions
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/onboarding/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Form
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<OnboardingFormsList />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ import {
|
|||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -43,13 +43,13 @@ const updateRoundSchema = z
|
|||
.object({
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
requiredReviews: z.number().int().min(1).max(10),
|
||||
votingStartAt: z.string().optional(),
|
||||
votingEndAt: z.string().optional(),
|
||||
votingStartAt: z.date().nullable().optional(),
|
||||
votingEndAt: z.date().nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.votingStartAt && data.votingEndAt) {
|
||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
||||
return data.votingEndAt > data.votingStartAt
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
|
@ -61,25 +61,19 @@ const updateRoundSchema = z
|
|||
|
||||
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
|
||||
|
||||
// Convert ISO date to datetime-local format
|
||||
function toDatetimeLocal(date: Date | string | null | undefined): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// Format: YYYY-MM-DDTHH:mm
|
||||
return format(d, "yyyy-MM-dd'T'HH:mm")
|
||||
}
|
||||
|
||||
function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
const router = useRouter()
|
||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
|
||||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
|
||||
// Fetch round data
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
|
||||
id: roundId,
|
||||
})
|
||||
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
|
||||
{ id: roundId },
|
||||
{ refetchOnWindowFocus: false }
|
||||
)
|
||||
|
||||
// Fetch evaluation form
|
||||
const { data: evaluationForm, isLoading: loadingForm } =
|
||||
|
|
@ -110,25 +104,26 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
defaultValues: {
|
||||
name: '',
|
||||
requiredReviews: 3,
|
||||
votingStartAt: '',
|
||||
votingEndAt: '',
|
||||
votingStartAt: null,
|
||||
votingEndAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Update form when round data loads
|
||||
// Update form when round data loads - only initialize once
|
||||
useEffect(() => {
|
||||
if (round) {
|
||||
if (round && !formInitialized) {
|
||||
form.reset({
|
||||
name: round.name,
|
||||
requiredReviews: round.requiredReviews,
|
||||
votingStartAt: toDatetimeLocal(round.votingStartAt),
|
||||
votingEndAt: toDatetimeLocal(round.votingEndAt),
|
||||
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
|
||||
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
|
||||
})
|
||||
// Set round type and settings
|
||||
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
|
||||
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
||||
setFormInitialized(true)
|
||||
}
|
||||
}, [round, form])
|
||||
}, [round, form, formInitialized])
|
||||
|
||||
// Initialize criteria from evaluation form
|
||||
useEffect(() => {
|
||||
|
|
@ -151,8 +146,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
requiredReviews: data.requiredReviews,
|
||||
roundType,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
|
|
@ -303,7 +298,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
<FormItem>
|
||||
<FormLabel>Start Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select start date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -317,7 +316,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
<FormItem>
|
||||
<FormLabel>End Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select end date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -326,7 +329,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to disable the voting window enforcement.
|
||||
Leave empty to disable the voting window enforcement. Past dates are allowed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,286 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Play,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { use, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Redirect to round details page - filtering is now integrated there
|
||||
export default function FilteringDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: roundId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const { data: round, isLoading: roundLoading } =
|
||||
trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: stats, isLoading: statsLoading, refetch: refetchStats } =
|
||||
trpc.filtering.getResultStats.useQuery({ roundId })
|
||||
const { data: rules } = trpc.filtering.getRules.useQuery({ roundId })
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const executeRules = trpc.filtering.executeRules.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
const result = await executeRules.mutateAsync({ roundId })
|
||||
toast.success(
|
||||
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
|
||||
)
|
||||
refetchStats()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to execute filtering'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinalize = async () => {
|
||||
try {
|
||||
const result = await finalizeResults.mutateAsync({ roundId })
|
||||
toast.success(
|
||||
`Finalized: ${result.passed} passed, ${result.filteredOut} filtered out`
|
||||
)
|
||||
refetchStats()
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to finalize'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (roundLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
router.replace(`/admin/rounds/${roundId}`)
|
||||
}, [router, roundId])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering — {round?.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure and run automated project screening
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={
|
||||
executeRules.isPending || !rules || rules.length === 0
|
||||
}
|
||||
>
|
||||
{executeRules.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Filtering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
) : stats && stats.total > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Filter className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{stats.passed}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Passed</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-500/10">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{stats.filteredOut}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Filtered Out</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{stats.flagged}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Flagged</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure rules and run filtering to screen projects
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Link href={`/admin/rounds/${roundId}/filtering/rules`}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ListChecks className="h-5 w-5" />
|
||||
Filtering Rules
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure field-based, document, and AI screening rules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="secondary">
|
||||
{rules?.length || 0} rule{(rules?.length || 0) !== 1 ? 's' : ''}{' '}
|
||||
configured
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href={`/admin/rounds/${roundId}/filtering/results`}>
|
||||
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-5 w-5" />
|
||||
Review Results
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Review outcomes, override decisions, and finalize filtering
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats && stats.total > 0 ? (
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
{stats.passed} passed
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-red-600">
|
||||
{stats.filteredOut} filtered
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-amber-600">
|
||||
{stats.flagged} flagged
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="secondary">No results yet</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Finalize */}
|
||||
{stats && stats.total > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Finalize Filtering</CardTitle>
|
||||
<CardDescription>
|
||||
Apply filtering outcomes to project statuses. Passed projects become
|
||||
Eligible. Filtered-out projects are set aside (not deleted) and can
|
||||
be reinstated at any time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={finalizeResults.isPending}
|
||||
variant="default"
|
||||
>
|
||||
{finalizeResults.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Finalize Results
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Redirecting to round details...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,9 +159,9 @@ export default function FilteringResultsPage({
|
|||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}/filtering`}>
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Filtering
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -208,9 +208,8 @@ export default function FilteringResultsPage({
|
|||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
<TableHead>Outcome</TableHead>
|
||||
<TableHead>Override</TableHead>
|
||||
<TableHead className="w-[300px]">AI Reason</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -221,6 +220,17 @@ export default function FilteringResultsPage({
|
|||
result.finalOutcome || result.outcome
|
||||
const badge = OUTCOME_BADGES[effectiveOutcome]
|
||||
|
||||
// Extract AI reasoning from aiScreeningJson
|
||||
const aiScreening = result.aiScreeningJson as Record<string, {
|
||||
meetsCriteria?: boolean
|
||||
confidence?: number
|
||||
reasoning?: string
|
||||
qualityScore?: number
|
||||
spamRisk?: boolean
|
||||
}> | null
|
||||
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
|
||||
const aiReasoning = firstAiResult?.reasoning
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
|
|
@ -235,6 +245,7 @@ export default function FilteringResultsPage({
|
|||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.project.teamName}
|
||||
{result.project.country && ` · ${result.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -251,26 +262,42 @@ export default function FilteringResultsPage({
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{result.project.country || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
{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,9 +337,11 @@ 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">
|
||||
<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 &&
|
||||
|
|
@ -329,25 +358,29 @@ export default function FilteringResultsPage({
|
|||
).map((rr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
{rr.passed ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<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}
|
||||
{rr.ruleType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{rr.reasoning && (
|
||||
<span className="text-muted-foreground">
|
||||
— {rr.reasoning}
|
||||
</span>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{rr.reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -355,22 +388,70 @@ export default function FilteringResultsPage({
|
|||
No detailed rule results available
|
||||
</p>
|
||||
)}
|
||||
{result.aiScreeningJson && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-sm font-medium">
|
||||
AI Screening Details
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 text-xs bg-muted rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(
|
||||
result.aiScreeningJson,
|
||||
null,
|
||||
2
|
||||
</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" />
|
||||
)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Override Info */}
|
||||
{result.overriddenByUser && (
|
||||
<div className="pt-3 border-t">
|
||||
<p className="text-sm font-medium mb-1">Manual Override</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
||||
{result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
</p>
|
||||
{result.overrideReason && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Reason: {result.overrideReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
|
@ -32,7 +32,6 @@ import {
|
|||
Edit,
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
|
|
@ -56,7 +55,7 @@ import { toast } from 'sonner'
|
|||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
|
||||
import { format, formatDistanceToNow, isFuture } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -67,14 +66,15 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
|
||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
|
||||
const isFilteringRound = roundType === 'FILTERING'
|
||||
// Check if this is a filtering round - roundType is stored directly on the round
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
|
||||
// Filtering queries (only fetch for FILTERING rounds)
|
||||
const { data: filteringStats, refetch: refetchFilteringStats } =
|
||||
trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
|
|
@ -88,6 +88,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound }
|
||||
)
|
||||
|
||||
// Poll for job status when there's an active job
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
{ jobId: activeJobId! },
|
||||
{
|
||||
enabled: !!activeJobId,
|
||||
refetchInterval: activeJobId ? 2000 : false,
|
||||
}
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
|
|
@ -108,19 +122,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
})
|
||||
|
||||
// Filtering mutations
|
||||
const executeRules = trpc.filtering.executeRules.useMutation()
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
const handleExecuteFiltering = async () => {
|
||||
try {
|
||||
const result = await executeRules.mutateAsync({ roundId })
|
||||
// Set active job from latest job on load
|
||||
useEffect(() => {
|
||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||
setActiveJobId(latestJob.id)
|
||||
}
|
||||
}, [latestJob])
|
||||
|
||||
// Handle job completion
|
||||
useEffect(() => {
|
||||
if (jobStatus?.status === 'COMPLETED') {
|
||||
toast.success(
|
||||
`Filtering complete: ${result.passed} passed, ${result.filteredOut} filtered out, ${result.flagged} flagged`
|
||||
`Filtering complete: ${jobStatus.passedCount} passed, ${jobStatus.filteredCount} filtered out, ${jobStatus.flaggedCount} flagged`
|
||||
)
|
||||
setActiveJobId(null)
|
||||
refetchFilteringStats()
|
||||
refetchLatestJob()
|
||||
} else if (jobStatus?.status === 'FAILED') {
|
||||
toast.error(`Filtering failed: ${jobStatus.errorMessage || 'Unknown error'}`)
|
||||
setActiveJobId(null)
|
||||
refetchLatestJob()
|
||||
}
|
||||
}, [jobStatus?.status, jobStatus?.passedCount, jobStatus?.filteredCount, jobStatus?.flaggedCount, jobStatus?.errorMessage, refetchFilteringStats, refetchLatestJob])
|
||||
|
||||
const handleStartFiltering = async () => {
|
||||
try {
|
||||
const result = await startJob.mutateAsync({ roundId })
|
||||
setActiveJobId(result.jobId)
|
||||
toast.info('Filtering job started. Progress will update automatically.')
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to execute filtering'
|
||||
error instanceof Error ? error.message : 'Failed to start filtering'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +176,11 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
}
|
||||
}
|
||||
|
||||
const isJobRunning = jobStatus?.status === 'RUNNING' || jobStatus?.status === 'PENDING'
|
||||
const progressPercent = jobStatus?.totalBatches
|
||||
? Math.round((jobStatus.currentBatch / jobStatus.totalBatches) * 100)
|
||||
: 0
|
||||
|
||||
if (isLoading) {
|
||||
return <RoundDetailSkeleton />
|
||||
}
|
||||
|
|
@ -475,20 +515,54 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExecuteFiltering}
|
||||
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
|
||||
onClick={handleStartFiltering}
|
||||
disabled={startJob.isPending || isJobRunning || !filteringRules || filteringRules.length === 0}
|
||||
>
|
||||
{executeRules.isPending ? (
|
||||
{startJob.isPending || isJobRunning ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Run Filtering
|
||||
{isJobRunning ? 'Running...' : 'Run Filtering'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress Card (when job is running) */}
|
||||
{isJobRunning && jobStatus && (
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
AI Filtering in Progress
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Processing {jobStatus.totalProjects} projects in {jobStatus.totalBatches} batches
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-blue-300 text-blue-700">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Batch {jobStatus.currentBatch} of {jobStatus.totalBatches}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-blue-700 dark:text-blue-300">
|
||||
{jobStatus.processedCount} of {jobStatus.totalProjects} projects processed
|
||||
</span>
|
||||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Status Warning */}
|
||||
{aiStatus?.hasAIRules && !aiStatus?.configured && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
|
|
@ -551,7 +625,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : !isJobRunning && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||
|
|
@ -581,7 +655,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
{filteringStats && filteringStats.total > 0 && (
|
||||
<Button
|
||||
onClick={handleFinalizeFiltering}
|
||||
disabled={finalizeResults.isPending}
|
||||
disabled={finalizeResults.isPending || isJobRunning}
|
||||
variant="default"
|
||||
>
|
||||
{finalizeResults.isPending ? (
|
||||
|
|
@ -644,14 +718,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
{!isFilteringRound && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/filtering`}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtering
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -35,16 +35,17 @@ import {
|
|||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
programId: z.string().min(1, 'Please select a program'),
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
requiredReviews: z.number().int().min(1).max(10),
|
||||
votingStartAt: z.string().optional(),
|
||||
votingEndAt: z.string().optional(),
|
||||
votingStartAt: z.date().nullable().optional(),
|
||||
votingEndAt: z.date().nullable().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.votingStartAt && data.votingEndAt) {
|
||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
||||
return data.votingEndAt > data.votingStartAt
|
||||
}
|
||||
return true
|
||||
}, {
|
||||
|
|
@ -75,8 +76,8 @@ function CreateRoundContent() {
|
|||
programId: programIdParam || '',
|
||||
name: '',
|
||||
requiredReviews: 3,
|
||||
votingStartAt: '',
|
||||
votingEndAt: '',
|
||||
votingStartAt: null,
|
||||
votingEndAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -87,8 +88,8 @@ function CreateRoundContent() {
|
|||
roundType,
|
||||
requiredReviews: data.requiredReviews,
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
|
||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +247,11 @@ function CreateRoundContent() {
|
|||
<FormItem>
|
||||
<FormLabel>Start Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select start date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -260,7 +265,11 @@ function CreateRoundContent() {
|
|||
<FormItem>
|
||||
<FormLabel>End Date & Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select end date & time"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -269,8 +278,7 @@ function CreateRoundContent() {
|
|||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leave empty to set the voting window later. The round will need to be
|
||||
activated before jury members can submit evaluations.
|
||||
Leave empty to set the voting window later. Past dates are allowed.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,676 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckCircle, AlertCircle, Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
||||
// Country list for country select special field
|
||||
const COUNTRIES = [
|
||||
'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Argentina', 'Armenia', 'Australia',
|
||||
'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium',
|
||||
'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei',
|
||||
'Bulgaria', 'Burkina Faso', 'Burundi', 'Cambodia', 'Cameroon', 'Canada', 'Cape Verde',
|
||||
'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo', 'Costa Rica',
|
||||
'Croatia', 'Cuba', 'Cyprus', 'Czech Republic', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic',
|
||||
'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini', 'Ethiopia',
|
||||
'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada',
|
||||
'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Honduras', 'Hungary', 'Iceland', 'India',
|
||||
'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan',
|
||||
'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia',
|
||||
'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives',
|
||||
'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova',
|
||||
'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar', 'Namibia', 'Nauru', 'Nepal',
|
||||
'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia',
|
||||
'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru',
|
||||
'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis',
|
||||
'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe',
|
||||
'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia',
|
||||
'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka',
|
||||
'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Taiwan', 'Tajikistan', 'Tanzania', 'Thailand',
|
||||
'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu',
|
||||
'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States', 'Uruguay', 'Uzbekistan',
|
||||
'Vanuatu', 'Vatican City', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',
|
||||
]
|
||||
|
||||
// Ocean issues for special field
|
||||
const OCEAN_ISSUES = [
|
||||
{ value: 'POLLUTION_REDUCTION', label: 'Pollution Reduction' },
|
||||
{ value: 'CLIMATE_MITIGATION', label: 'Climate Mitigation' },
|
||||
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology Innovation' },
|
||||
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable Shipping' },
|
||||
{ value: 'BLUE_CARBON', label: 'Blue Carbon' },
|
||||
{ value: 'HABITAT_RESTORATION', label: 'Habitat Restoration' },
|
||||
{ value: 'COMMUNITY_CAPACITY', label: 'Community Capacity Building' },
|
||||
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable Fishing' },
|
||||
{ value: 'CONSUMER_AWARENESS', label: 'Consumer Awareness' },
|
||||
{ value: 'OCEAN_ACIDIFICATION', label: 'Ocean Acidification' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
// Competition categories for special field
|
||||
const COMPETITION_CATEGORIES = [
|
||||
{ value: 'STARTUP', label: 'Startup - Existing company with traction' },
|
||||
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept - Student/graduate project' },
|
||||
]
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
type FieldType = {
|
||||
id: string
|
||||
fieldType: string
|
||||
name: string
|
||||
label: string
|
||||
description?: string | null
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
minValue?: number | null
|
||||
maxValue?: number | null
|
||||
optionsJson: unknown
|
||||
conditionJson: unknown
|
||||
width: string
|
||||
specialType?: string | null
|
||||
projectMapping?: string | null
|
||||
}
|
||||
|
||||
type StepType = {
|
||||
id: string
|
||||
name: string
|
||||
title: string
|
||||
description?: string | null
|
||||
isOptional: boolean
|
||||
fields: FieldType[]
|
||||
}
|
||||
|
||||
export default function OnboardingWizardPage({ params }: PageProps) {
|
||||
const { slug } = use(params)
|
||||
const router = useRouter()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
|
||||
|
||||
// Fetch onboarding config
|
||||
const { data: config, isLoading, error } = trpc.onboarding.getConfig.useQuery(
|
||||
{ slug },
|
||||
{ retry: false }
|
||||
)
|
||||
|
||||
// Form state
|
||||
const { control, handleSubmit, watch, setValue, formState: { errors }, trigger } = useForm({
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const watchedValues = watch()
|
||||
|
||||
// Submit mutation
|
||||
const submitMutation = trpc.onboarding.submit.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setSubmitted(true)
|
||||
setConfirmationMessage(result.confirmationMessage || null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Submission failed')
|
||||
},
|
||||
})
|
||||
|
||||
const steps = config?.steps || []
|
||||
const currentStep = steps[currentStepIndex]
|
||||
const isLastStep = currentStepIndex === steps.length - 1
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
||||
|
||||
// Navigate between steps
|
||||
const goToNextStep = async () => {
|
||||
// Validate current step fields
|
||||
const currentFields = currentStep?.fields || []
|
||||
const fieldNames = currentFields.map((f) => f.name)
|
||||
const isValid = await trigger(fieldNames)
|
||||
|
||||
if (!isValid) {
|
||||
toast.error('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
// Submit the form
|
||||
const allData = watchedValues
|
||||
await submitMutation.mutateAsync({
|
||||
formId: config!.form.id,
|
||||
contactName: allData.contactName || allData.name || '',
|
||||
contactEmail: allData.contactEmail || allData.email || '',
|
||||
contactPhone: allData.contactPhone || allData.phone,
|
||||
projectName: allData.projectName || allData.title || '',
|
||||
description: allData.description,
|
||||
competitionCategory: allData.competitionCategory,
|
||||
oceanIssue: allData.oceanIssue,
|
||||
country: allData.country,
|
||||
institution: allData.institution,
|
||||
teamName: allData.teamName,
|
||||
wantsMentorship: allData.wantsMentorship,
|
||||
referralSource: allData.referralSource,
|
||||
foundedAt: allData.foundedAt,
|
||||
teamMembers: allData.teamMembers,
|
||||
metadata: allData,
|
||||
gdprConsent: allData.gdprConsent || false,
|
||||
})
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPrevStep = () => {
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
// Render field based on type and special type
|
||||
const renderField = (field: FieldType) => {
|
||||
const errorMessage = errors[field.name]?.message as string | undefined
|
||||
|
||||
// Handle special field types
|
||||
if (field.specialType) {
|
||||
switch (field.specialType) {
|
||||
case 'COMPETITION_CATEGORY':
|
||||
return (
|
||||
<div key={field.id} className="space-y-3">
|
||||
<Label>
|
||||
Competition Category
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a category' : false }}
|
||||
render={({ field: f }) => (
|
||||
<RadioGroup value={f.value} onValueChange={f.onChange} className="space-y-3">
|
||||
{COMPETITION_CATEGORIES.map((cat) => (
|
||||
<div
|
||||
key={cat.value}
|
||||
className={cn(
|
||||
'flex items-start space-x-3 p-4 rounded-lg border cursor-pointer transition-colors',
|
||||
f.value === cat.value ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={() => f.onChange(cat.value)}
|
||||
>
|
||||
<RadioGroupItem value={cat.value} id={cat.value} className="mt-0.5" />
|
||||
<Label htmlFor={cat.value} className="font-normal cursor-pointer">
|
||||
{cat.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'OCEAN_ISSUE':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
Ocean Issue Focus
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select an ocean issue' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select the primary ocean issue your project addresses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OCEAN_ISSUES.map((issue) => (
|
||||
<SelectItem key={issue.value} value={issue.value}>
|
||||
{issue.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'COUNTRY_SELECT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label>
|
||||
{field.label || 'Country'}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? 'Please select a country' : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COUNTRIES.map((country) => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'GDPR_CONSENT':
|
||||
return (
|
||||
<div key={field.id} className="space-y-4">
|
||||
<div className="p-4 bg-muted rounded-lg text-sm">
|
||||
<p className="font-medium mb-2">Terms & Conditions</p>
|
||||
<p className="text-muted-foreground">
|
||||
By submitting this application, you agree to our terms of service and privacy policy.
|
||||
Your data will be processed in accordance with GDPR regulations.
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => value === true || 'You must accept the terms and conditions'
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<Label htmlFor={field.name} className="font-normal leading-tight cursor-pointer">
|
||||
I accept the terms and conditions and consent to the processing of my data
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Standard field types
|
||||
switch (field.fieldType) {
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'PHONE':
|
||||
case 'URL':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
pattern: field.fieldType === 'EMAIL' ? { value: /^\S+@\S+$/i, message: 'Invalid email address' } : undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
|
||||
placeholder={field.placeholder || undefined}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'TEXTAREA':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
|
||||
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
placeholder={field.placeholder || undefined}
|
||||
rows={4}
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'SELECT':
|
||||
const options = (field.optionsJson as Array<{ value: string; label: string }>) || []
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Select value={f.value} onValueChange={f.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'CHECKBOX':
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: field.required
|
||||
? (value) => value === true || `${field.label} is required`
|
||||
: undefined,
|
||||
}}
|
||||
render={({ field: f }) => (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={f.value || false}
|
||||
onCheckedChange={f.onChange}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={field.name} className="font-normal cursor-pointer">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'DATE':
|
||||
return (
|
||||
<div key={field.id} className={cn('space-y-2', field.width === 'half' && 'col-span-1')}>
|
||||
<Label htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{ required: field.required ? `${field.label} is required` : false }}
|
||||
render={({ field: f }) => (
|
||||
<Input
|
||||
id={field.name}
|
||||
type="date"
|
||||
value={f.value || ''}
|
||||
onChange={f.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Application Not Available</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{error.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-full bg-green-100 p-3 mb-4">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Application Submitted!</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{confirmationMessage || 'Thank you for your submission. We will review your application and get back to you soon.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!config || steps.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">Form Not Configured</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
This application form has not been configured yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
|
||||
<div className="max-w-2xl mx-auto px-4 py-12">
|
||||
{/* Header */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Logo showText />
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Step {currentStepIndex + 1} of {steps.length}</span>
|
||||
<span>{Math.round(progress)}% complete</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex justify-between mt-4">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
index < currentStepIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: index === currentStepIndex
|
||||
? 'bg-primary text-primary-foreground ring-4 ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{index < currentStepIndex ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{currentStep?.title}</CardTitle>
|
||||
{currentStep?.description && (
|
||||
<CardDescription>{currentStep.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => { e.preventDefault(); goToNextStep(); }}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{currentStep?.fields.map((field) => (
|
||||
<div key={field.id} className={cn(field.width === 'half' ? '' : 'col-span-full')}>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goToPrevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={goToNextStep}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : isLastStep ? (
|
||||
<>
|
||||
Submit Application
|
||||
<Check className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-8">
|
||||
{config.program?.name} {config.program?.year && `${config.program.year}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import { appRouter } from '@/server/routers/_app'
|
|||
import { createContext } from '@/server/context'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
// Allow long-running operations (AI filtering, bulk imports)
|
||||
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
|
||||
export const maxDuration = 300 // 5 minutes
|
||||
|
||||
const RATE_LIMIT = 100 // requests per window
|
||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||
|
||||
|
|
|
|||
|
|
@ -91,8 +91,8 @@ const navigation = [
|
|||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
name: 'Forms',
|
||||
href: '/admin/forms' as const,
|
||||
name: 'Onboarding',
|
||||
href: '/admin/onboarding' as const,
|
||||
icon: FileText,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
146
src/lib/email.ts
146
src/lib/email.ts
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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() }))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue