From e2782b2b197fda51bcb90083a1e8e3345d062e8b Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Feb 2026 19:48:41 +0100 Subject: [PATCH] Add background filtering jobs, improved date picker, AI reasoning display - Implement background job system for AI filtering to avoid HTTP timeouts - Add FilteringJob model to track progress of long-running filtering operations - Add real-time progress polling for filtering operations on round details page - Create custom DateTimePicker component with calendar popup (no year picker hassle) - Fix round date persistence bug (refetchOnWindowFocus was resetting form state) - Integrate filtering controls into round details page for filtering rounds - Display AI reasoning for flagged/filtered projects in results table - Add onboarding system scaffolding (schema, routes, basic UI) - Allow setting round dates in the past for manual overrides Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 34 + package.json | 1 + .../migration.sql | 147 ++++ .../migration.sql | 38 + prisma/schema.prisma | 100 ++- .../(admin)/admin/onboarding/[id]/page.tsx | 686 ++++++++++++++++++ src/app/(admin)/admin/onboarding/new/page.tsx | 171 +++++ src/app/(admin)/admin/onboarding/page.tsx | 153 ++++ .../(admin)/admin/rounds/[id]/edit/page.tsx | 59 +- .../admin/rounds/[id]/filtering/page.tsx | 280 +------ .../rounds/[id]/filtering/results/page.tsx | 237 ++++-- src/app/(admin)/admin/rounds/[id]/page.tsx | 118 ++- src/app/(admin)/admin/rounds/new/page.tsx | 30 +- src/app/(public)/apply/[slug]/wizard/page.tsx | 676 +++++++++++++++++ src/app/api/trpc/[trpc]/route.ts | 4 + src/components/layouts/admin-sidebar.tsx | 4 +- src/components/ui/calendar.tsx | 72 ++ src/components/ui/datetime-picker.tsx | 192 +++++ src/lib/email.ts | 146 ++++ src/server/routers/_app.ts | 2 + src/server/routers/applicationForm.ts | 313 +++++++- src/server/routers/filtering.ts | 241 +++++- src/server/routers/onboarding.ts | 398 ++++++++++ src/server/services/ai-filtering.ts | 33 +- 24 files changed, 3692 insertions(+), 443 deletions(-) create mode 100644 prisma/migrations/20260203200000_add_onboarding_system/migration.sql create mode 100644 prisma/migrations/20260203210000_add_filtering_job/migration.sql create mode 100644 src/app/(admin)/admin/onboarding/[id]/page.tsx create mode 100644 src/app/(admin)/admin/onboarding/new/page.tsx create mode 100644 src/app/(admin)/admin/onboarding/page.tsx create mode 100644 src/app/(public)/apply/[slug]/wizard/page.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/datetime-picker.tsx create mode 100644 src/server/routers/onboarding.ts diff --git a/package-lock.json b/package-lock.json index e41491c..67eecb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0f40791..d7c7674 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260203200000_add_onboarding_system/migration.sql b/prisma/migrations/20260203200000_add_onboarding_system/migration.sql new file mode 100644 index 0000000..3c1025c --- /dev/null +++ b/prisma/migrations/20260203200000_add_onboarding_system/migration.sql @@ -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 $$; diff --git a/prisma/migrations/20260203210000_add_filtering_job/migration.sql b/prisma/migrations/20260203210000_add_filtering_job/migration.sql new file mode 100644 index 0000000..0725eba --- /dev/null +++ b/prisma/migrations/20260203210000_add_filtering_job/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30a20be..2636a63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,15 @@ enum FormFieldType { INSTRUCTIONS } +enum SpecialFieldType { + TEAM_MEMBERS // Team member repeater + COMPETITION_CATEGORY // Business Concept vs Startup + OCEAN_ISSUE // Ocean issue dropdown + FILE_UPLOAD // File upload + GDPR_CONSENT // GDPR consent checkbox + COUNTRY_SELECT // Country dropdown +} + // ============================================================================= // APPLICANT SYSTEM ENUMS // ============================================================================= @@ -376,14 +385,16 @@ model Round { updatedAt DateTime @updatedAt // Relations - program Program @relation(fields: [programId], references: [id], onDelete: Cascade) - roundProjects RoundProject[] - assignments Assignment[] - evaluationForms EvaluationForm[] - gracePeriods GracePeriod[] + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + roundProjects RoundProject[] + assignments Assignment[] + evaluationForms EvaluationForm[] + gracePeriods GracePeriod[] liveVotingSession LiveVotingSession? - filteringRules FilteringRule[] - filteringResults FilteringResult[] + filteringRules FilteringRule[] + filteringResults FilteringResult[] + filteringJobs FilteringJob[] + applicationForm ApplicationForm? @@index([programId]) @@index([status]) @@ -858,22 +869,35 @@ model ApplicationForm { confirmationMessage String? @db.Text + // Round linking (for onboarding forms that create projects) + roundId String? @unique + + // Email settings + sendConfirmationEmail Boolean @default(true) + sendTeamInviteEmails Boolean @default(true) + confirmationEmailSubject String? + confirmationEmailBody String? @db.Text + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations program Program? @relation(fields: [programId], references: [id], onDelete: SetNull) + round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull) fields ApplicationFormField[] + steps OnboardingStep[] submissions ApplicationFormSubmission[] @@index([programId]) @@index([status]) @@index([isPublic]) + @@index([roundId]) } model ApplicationFormField { id String @id @default(cuid()) formId String + stepId String? // Which step this field belongs to (for onboarding) fieldType FormFieldType name String // Internal name (e.g., "project_title") label String // Display label (e.g., "Project Title") @@ -889,6 +913,10 @@ model ApplicationFormField { optionsJson Json? @db.JsonB // For select/radio: [{ value, label }] conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value } + // Onboarding-specific fields + projectMapping String? // Maps to Project column: "title", "description", etc. + specialType SpecialFieldType? // Special handling for complex fields + sortOrder Int @default(0) width String @default("full") // full, half @@ -896,7 +924,30 @@ model ApplicationFormField { updatedAt DateTime @updatedAt // Relations - form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade) + form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade) + step OnboardingStep? @relation(fields: [stepId], references: [id], onDelete: SetNull) + + @@index([formId]) + @@index([stepId]) + @@index([sortOrder]) +} + +model OnboardingStep { + id String @id @default(cuid()) + formId String + name String // Internal identifier (e.g., "category", "contact") + title String // Display title (e.g., "Category", "Contact Information") + description String? @db.Text + sortOrder Int @default(0) + isOptional Boolean @default(false) + conditionJson Json? @db.JsonB // Conditional visibility: { fieldId, operator, value } + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade) + fields ApplicationFormField[] @@index([formId]) @@index([sortOrder]) @@ -1117,6 +1168,39 @@ model FilteringResult { @@index([outcome]) } +// Tracks progress of long-running filtering jobs +model FilteringJob { + id String @id @default(cuid()) + roundId String + status FilteringJobStatus @default(PENDING) + totalProjects Int @default(0) + totalBatches Int @default(0) + currentBatch Int @default(0) + processedCount Int @default(0) + passedCount Int @default(0) + filteredCount Int @default(0) + flaggedCount Int @default(0) + errorMessage String? @db.Text + startedAt DateTime? + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + + @@index([roundId]) + @@index([status]) +} + +enum FilteringJobStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + // ============================================================================= // SPECIAL AWARDS SYSTEM // ============================================================================= diff --git a/src/app/(admin)/admin/onboarding/[id]/page.tsx b/src/app/(admin)/admin/onboarding/[id]/page.tsx new file mode 100644 index 0000000..d3e517d --- /dev/null +++ b/src/app/(admin)/admin/onboarding/[id]/page.tsx @@ -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 ( +
+ +
+

{step.title}

+

+ {(step.fields as unknown[]).length} fields +

+
+ +
+ ) +} + +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(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 + } + + if (!form) { + return ( +
+

Form not found

+ + + +
+ ) + } + + return ( +
+ {/* Header */} +
+ +
+ +
+
+

{form.name}

+
+ + {form.status} + + {form.round && ( + + + {form.round.name} + + )} +
+
+
+ {form.publicSlug && form.status === 'PUBLISHED' && ( + + + + )} +
+
+ + {/* Tabs */} + + + Steps & Fields + Settings + Emails + + + {/* Steps Tab */} + +
+ {/* Steps List */} + + + Steps + + Drag to reorder wizard steps + + + + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {form.steps.map((step) => ( + setSelectedStepId(step.id)} + onDelete={() => { + if (confirm('Delete this step? Fields will be unassigned.')) { + deleteStep.mutate({ id: step.id }) + } + }} + /> + ))} +
+
+
+ + +
+
+ + {/* Step Editor */} + + + + {selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'} + + + + {selectedStep ? ( +
+
+
+ + { + updateStep.mutate({ + id: selectedStep.id, + step: { title: e.target.value }, + }) + }} + /> +
+
+ + { + updateStep.mutate({ + id: selectedStep.id, + step: { name: e.target.value }, + }) + }} + /> +
+
+ +
+ +