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",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
|
@ -268,6 +269,12 @@
|
||||||
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
|
@ -6343,6 +6350,12 @@
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"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": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
|
@ -11638,6 +11651,27 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.54.2",
|
"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
|
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
|
// APPLICANT SYSTEM ENUMS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -376,14 +385,16 @@ model Round {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||||
roundProjects RoundProject[]
|
roundProjects RoundProject[]
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
evaluationForms EvaluationForm[]
|
evaluationForms EvaluationForm[]
|
||||||
gracePeriods GracePeriod[]
|
gracePeriods GracePeriod[]
|
||||||
liveVotingSession LiveVotingSession?
|
liveVotingSession LiveVotingSession?
|
||||||
filteringRules FilteringRule[]
|
filteringRules FilteringRule[]
|
||||||
filteringResults FilteringResult[]
|
filteringResults FilteringResult[]
|
||||||
|
filteringJobs FilteringJob[]
|
||||||
|
applicationForm ApplicationForm?
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
|
@ -858,22 +869,35 @@ model ApplicationForm {
|
||||||
|
|
||||||
confirmationMessage String? @db.Text
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
|
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
fields ApplicationFormField[]
|
fields ApplicationFormField[]
|
||||||
|
steps OnboardingStep[]
|
||||||
submissions ApplicationFormSubmission[]
|
submissions ApplicationFormSubmission[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
|
@@index([roundId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ApplicationFormField {
|
model ApplicationFormField {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
formId String
|
formId String
|
||||||
|
stepId String? // Which step this field belongs to (for onboarding)
|
||||||
fieldType FormFieldType
|
fieldType FormFieldType
|
||||||
name String // Internal name (e.g., "project_title")
|
name String // Internal name (e.g., "project_title")
|
||||||
label String // Display label (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 }]
|
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
|
||||||
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
|
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)
|
sortOrder Int @default(0)
|
||||||
width String @default("full") // full, half
|
width String @default("full") // full, half
|
||||||
|
|
||||||
|
|
@ -896,7 +924,30 @@ model ApplicationFormField {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// 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([formId])
|
||||||
@@index([sortOrder])
|
@@index([sortOrder])
|
||||||
|
|
@ -1117,6 +1168,39 @@ model FilteringResult {
|
||||||
@@index([outcome])
|
@@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
|
// 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'
|
} from '@/components/forms/evaluation-form-builder'
|
||||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
|
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -43,13 +43,13 @@ const updateRoundSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, 'Name is required').max(255),
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
requiredReviews: z.number().int().min(1).max(10),
|
requiredReviews: z.number().int().min(1).max(10),
|
||||||
votingStartAt: z.string().optional(),
|
votingStartAt: z.date().nullable().optional(),
|
||||||
votingEndAt: z.string().optional(),
|
votingEndAt: z.date().nullable().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.votingStartAt && data.votingEndAt) {
|
if (data.votingStartAt && data.votingEndAt) {
|
||||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
return data.votingEndAt > data.votingStartAt
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
@ -61,25 +61,19 @@ const updateRoundSchema = z
|
||||||
|
|
||||||
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
|
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 }) {
|
function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||||
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
|
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
|
||||||
|
const [formInitialized, setFormInitialized] = useState(false)
|
||||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||||
|
|
||||||
// Fetch round data
|
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
|
||||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
|
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
|
||||||
id: roundId,
|
{ id: roundId },
|
||||||
})
|
{ refetchOnWindowFocus: false }
|
||||||
|
)
|
||||||
|
|
||||||
// Fetch evaluation form
|
// Fetch evaluation form
|
||||||
const { data: evaluationForm, isLoading: loadingForm } =
|
const { data: evaluationForm, isLoading: loadingForm } =
|
||||||
|
|
@ -110,25 +104,26 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
requiredReviews: 3,
|
requiredReviews: 3,
|
||||||
votingStartAt: '',
|
votingStartAt: null,
|
||||||
votingEndAt: '',
|
votingEndAt: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update form when round data loads
|
// Update form when round data loads - only initialize once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (round) {
|
if (round && !formInitialized) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: round.name,
|
name: round.name,
|
||||||
requiredReviews: round.requiredReviews,
|
requiredReviews: round.requiredReviews,
|
||||||
votingStartAt: toDatetimeLocal(round.votingStartAt),
|
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
|
||||||
votingEndAt: toDatetimeLocal(round.votingEndAt),
|
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
|
||||||
})
|
})
|
||||||
// Set round type and settings
|
// Set round type and settings
|
||||||
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
|
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
|
||||||
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
||||||
|
setFormInitialized(true)
|
||||||
}
|
}
|
||||||
}, [round, form])
|
}, [round, form, formInitialized])
|
||||||
|
|
||||||
// Initialize criteria from evaluation form
|
// Initialize criteria from evaluation form
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -151,8 +146,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
requiredReviews: data.requiredReviews,
|
requiredReviews: data.requiredReviews,
|
||||||
roundType,
|
roundType,
|
||||||
settingsJson: roundSettings,
|
settingsJson: roundSettings,
|
||||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
|
votingStartAt: data.votingStartAt ?? null,
|
||||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
|
votingEndAt: data.votingEndAt ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update evaluation form if criteria changed and no evaluations exist
|
// Update evaluation form if criteria changed and no evaluations exist
|
||||||
|
|
@ -303,7 +298,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Start Date & Time</FormLabel>
|
<FormLabel>Start Date & Time</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="datetime-local" {...field} />
|
<DateTimePicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Select start date & time"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -317,7 +316,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>End Date & Time</FormLabel>
|
<FormLabel>End Date & Time</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="datetime-local" {...field} />
|
<DateTimePicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Select end date & time"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -326,7 +329,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,286 +1,24 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { use } from 'react'
|
import { use, useEffect } 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 {
|
|
||||||
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'
|
|
||||||
|
|
||||||
|
// Redirect to round details page - filtering is now integrated there
|
||||||
export default function FilteringDashboardPage({
|
export default function FilteringDashboardPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id: roundId } = use(params)
|
const { id: roundId } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const { data: round, isLoading: roundLoading } =
|
useEffect(() => {
|
||||||
trpc.round.get.useQuery({ id: roundId })
|
router.replace(`/admin/rounds/${roundId}`)
|
||||||
const { data: stats, isLoading: statsLoading, refetch: refetchStats } =
|
}, [router, roundId])
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex items-center justify-center py-12">
|
||||||
{/* Header */}
|
<p className="text-muted-foreground">Redirecting to round details...</p>
|
||||||
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,9 +159,9 @@ export default function FilteringResultsPage({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" asChild className="-ml-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" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Filtering
|
Back to Round
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,9 +208,8 @@ export default function FilteringResultsPage({
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Country</TableHead>
|
|
||||||
<TableHead>Outcome</TableHead>
|
<TableHead>Outcome</TableHead>
|
||||||
<TableHead>Override</TableHead>
|
<TableHead className="w-[300px]">AI Reason</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -221,6 +220,17 @@ export default function FilteringResultsPage({
|
||||||
result.finalOutcome || result.outcome
|
result.finalOutcome || result.outcome
|
||||||
const badge = OUTCOME_BADGES[effectiveOutcome]
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
|
|
@ -235,6 +245,7 @@ export default function FilteringResultsPage({
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{result.project.teamName}
|
{result.project.teamName}
|
||||||
|
{result.project.country && ` · ${result.project.country}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -251,26 +262,42 @@ export default function FilteringResultsPage({
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{result.project.country || '-'}
|
<div className="space-y-1">
|
||||||
|
<Badge variant={badge?.variant || 'secondary'}>
|
||||||
|
{badge?.icon}
|
||||||
|
{badge?.label || effectiveOutcome}
|
||||||
|
</Badge>
|
||||||
|
{result.overriddenByUser && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={badge?.variant || 'secondary'}>
|
{aiReasoning ? (
|
||||||
{badge?.icon}
|
<div className="space-y-1">
|
||||||
{badge?.label || effectiveOutcome}
|
<p className="text-sm line-clamp-2">
|
||||||
</Badge>
|
{aiReasoning}
|
||||||
</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}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
<span className="text-sm text-muted-foreground italic">
|
||||||
|
No AI screening
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
@ -310,67 +337,121 @@ export default function FilteringResultsPage({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<TableRow key={`${result.id}-detail`}>
|
<TableRow key={`${result.id}-detail`}>
|
||||||
<TableCell colSpan={6} className="bg-muted/30">
|
<TableCell colSpan={5} className="bg-muted/30">
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-4">
|
||||||
<p className="text-sm font-medium">
|
{/* Rule Results */}
|
||||||
Rule Results
|
<div>
|
||||||
</p>
|
<p className="text-sm font-medium mb-2">
|
||||||
{result.ruleResultsJson &&
|
Rule Results
|
||||||
Array.isArray(result.ruleResultsJson) ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(
|
|
||||||
result.ruleResultsJson as Array<{
|
|
||||||
ruleName: string
|
|
||||||
ruleType: string
|
|
||||||
passed: boolean
|
|
||||||
action: string
|
|
||||||
reasoning?: string
|
|
||||||
}>
|
|
||||||
).map((rr, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
>
|
|
||||||
{rr.passed ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
<span className="font-medium">
|
|
||||||
{rr.ruleName}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{rr.ruleType}
|
|
||||||
</Badge>
|
|
||||||
{rr.reasoning && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
— {rr.reasoning}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No detailed rule results available
|
|
||||||
</p>
|
</p>
|
||||||
|
{result.ruleResultsJson &&
|
||||||
|
Array.isArray(result.ruleResultsJson) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(
|
||||||
|
result.ruleResultsJson as Array<{
|
||||||
|
ruleName: string
|
||||||
|
ruleType: string
|
||||||
|
passed: boolean
|
||||||
|
action: string
|
||||||
|
reasoning?: string
|
||||||
|
}>
|
||||||
|
).map((rr, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{rr.passed ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{rr.ruleName}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{rr.ruleType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{rr.reasoning && (
|
||||||
|
<p className="text-muted-foreground mt-0.5">
|
||||||
|
{rr.reasoning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No detailed rule results available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Screening Details */}
|
||||||
|
{aiScreening && Object.keys(aiScreening).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
AI Screening Analysis
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(aiScreening).map(([ruleId, screening]) => (
|
||||||
|
<div key={ruleId} className="p-3 bg-background rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{screening.meetsCriteria ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
|
||||||
|
</span>
|
||||||
|
{screening.spamRisk && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||||
|
Spam Risk
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{screening.reasoning && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{screening.reasoning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
{screening.confidence !== undefined && (
|
||||||
|
<span>
|
||||||
|
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{screening.qualityScore !== undefined && (
|
||||||
|
<span>
|
||||||
|
Quality Score: <strong>{screening.qualityScore}/10</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{result.aiScreeningJson && (
|
|
||||||
<Collapsible>
|
{/* Override Info */}
|
||||||
<CollapsibleTrigger className="flex items-center gap-1 text-sm font-medium">
|
{result.overriddenByUser && (
|
||||||
AI Screening Details
|
<div className="pt-3 border-t">
|
||||||
<ChevronDown className="h-3 w-3" />
|
<p className="text-sm font-medium mb-1">Manual Override</p>
|
||||||
</CollapsibleTrigger>
|
<p className="text-sm text-muted-foreground">
|
||||||
<CollapsibleContent>
|
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
||||||
<pre className="mt-2 text-xs bg-muted rounded p-2 overflow-x-auto">
|
{result.overriddenByUser.name || result.overriddenByUser.email}
|
||||||
{JSON.stringify(
|
</p>
|
||||||
result.aiScreeningJson,
|
{result.overrideReason && (
|
||||||
null,
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
2
|
Reason: {result.overrideReason}
|
||||||
)}
|
</p>
|
||||||
</pre>
|
)}
|
||||||
</CollapsibleContent>
|
</div>
|
||||||
</Collapsible>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Suspense, use, useState } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
@ -32,7 +32,6 @@ import {
|
||||||
Edit,
|
Edit,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -56,7 +55,7 @@ import { toast } from 'sonner'
|
||||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||||
import { RemoveProjectsDialog } from '@/components/admin/remove-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 {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -67,14 +66,15 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
const [assignOpen, setAssignOpen] = useState(false)
|
const [assignOpen, setAssignOpen] = useState(false)
|
||||||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||||
const [removeOpen, setRemoveOpen] = 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: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||||
|
|
||||||
// Filtering queries (only fetch for FILTERING rounds)
|
// Check if this is a filtering round - roundType is stored directly on the round
|
||||||
const roundType = (round?.settingsJson as { roundType?: string } | null)?.roundType
|
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||||
const isFilteringRound = roundType === 'FILTERING'
|
|
||||||
|
|
||||||
|
// Filtering queries (only fetch for FILTERING rounds)
|
||||||
const { data: filteringStats, refetch: refetchFilteringStats } =
|
const { data: filteringStats, refetch: refetchFilteringStats } =
|
||||||
trpc.filtering.getResultStats.useQuery(
|
trpc.filtering.getResultStats.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
|
|
@ -88,6 +88,20 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: isFilteringRound }
|
{ 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 utils = trpc.useUtils()
|
||||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||||
|
|
@ -108,19 +122,40 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtering mutations
|
// Filtering mutations
|
||||||
const executeRules = trpc.filtering.executeRules.useMutation()
|
const startJob = trpc.filtering.startJob.useMutation()
|
||||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||||
|
|
||||||
const handleExecuteFiltering = async () => {
|
// Set active job from latest job on load
|
||||||
try {
|
useEffect(() => {
|
||||||
const result = await executeRules.mutateAsync({ roundId })
|
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||||
|
setActiveJobId(latestJob.id)
|
||||||
|
}
|
||||||
|
}, [latestJob])
|
||||||
|
|
||||||
|
// Handle job completion
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobStatus?.status === 'COMPLETED') {
|
||||||
toast.success(
|
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()
|
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) {
|
} catch (error) {
|
||||||
toast.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) {
|
if (isLoading) {
|
||||||
return <RoundDetailSkeleton />
|
return <RoundDetailSkeleton />
|
||||||
}
|
}
|
||||||
|
|
@ -475,20 +515,54 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleExecuteFiltering}
|
onClick={handleStartFiltering}
|
||||||
disabled={executeRules.isPending || !filteringRules || filteringRules.length === 0}
|
disabled={startJob.isPending || isJobRunning || !filteringRules || filteringRules.length === 0}
|
||||||
>
|
>
|
||||||
{executeRules.isPending ? (
|
{startJob.isPending || isJobRunning ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Run Filtering
|
{isJobRunning ? 'Running...' : 'Run Filtering'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 */}
|
{/* AI Status Warning */}
|
||||||
{aiStatus?.hasAIRules && !aiStatus?.configured && (
|
{aiStatus?.hasAIRules && !aiStatus?.configured && (
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !isJobRunning && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||||
<p className="mt-2 font-medium">No filtering results yet</p>
|
<p className="mt-2 font-medium">No filtering results yet</p>
|
||||||
|
|
@ -581,7 +655,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
{filteringStats && filteringStats.total > 0 && (
|
{filteringStats && filteringStats.total > 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinalizeFiltering}
|
onClick={handleFinalizeFiltering}
|
||||||
disabled={finalizeResults.isPending}
|
disabled={finalizeResults.isPending || isJobRunning}
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
{finalizeResults.isPending ? (
|
{finalizeResults.isPending ? (
|
||||||
|
|
@ -644,14 +718,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Jury Assignments
|
Jury Assignments
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,17 @@ import {
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||||
|
|
||||||
const createRoundSchema = z.object({
|
const createRoundSchema = z.object({
|
||||||
programId: z.string().min(1, 'Please select a program'),
|
programId: z.string().min(1, 'Please select a program'),
|
||||||
name: z.string().min(1, 'Name is required').max(255),
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
requiredReviews: z.number().int().min(1).max(10),
|
requiredReviews: z.number().int().min(1).max(10),
|
||||||
votingStartAt: z.string().optional(),
|
votingStartAt: z.date().nullable().optional(),
|
||||||
votingEndAt: z.string().optional(),
|
votingEndAt: z.date().nullable().optional(),
|
||||||
}).refine((data) => {
|
}).refine((data) => {
|
||||||
if (data.votingStartAt && data.votingEndAt) {
|
if (data.votingStartAt && data.votingEndAt) {
|
||||||
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
|
return data.votingEndAt > data.votingStartAt
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}, {
|
}, {
|
||||||
|
|
@ -75,8 +76,8 @@ function CreateRoundContent() {
|
||||||
programId: programIdParam || '',
|
programId: programIdParam || '',
|
||||||
name: '',
|
name: '',
|
||||||
requiredReviews: 3,
|
requiredReviews: 3,
|
||||||
votingStartAt: '',
|
votingStartAt: null,
|
||||||
votingEndAt: '',
|
votingEndAt: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -87,8 +88,8 @@ function CreateRoundContent() {
|
||||||
roundType,
|
roundType,
|
||||||
requiredReviews: data.requiredReviews,
|
requiredReviews: data.requiredReviews,
|
||||||
settingsJson: roundSettings,
|
settingsJson: roundSettings,
|
||||||
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
|
votingStartAt: data.votingStartAt ?? undefined,
|
||||||
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
|
votingEndAt: data.votingEndAt ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +247,11 @@ function CreateRoundContent() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Start Date & Time</FormLabel>
|
<FormLabel>Start Date & Time</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="datetime-local" {...field} />
|
<DateTimePicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Select start date & time"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -260,7 +265,11 @@ function CreateRoundContent() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>End Date & Time</FormLabel>
|
<FormLabel>End Date & Time</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="datetime-local" {...field} />
|
<DateTimePicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="Select end date & time"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -269,8 +278,7 @@ function CreateRoundContent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Leave empty to set the voting window later. The round will need to be
|
Leave empty to set the voting window later. Past dates are allowed.
|
||||||
activated before jury members can submit evaluations.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 { createContext } from '@/server/context'
|
||||||
import { checkRateLimit } from '@/lib/rate-limit'
|
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_LIMIT = 100 // requests per window
|
||||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ const navigation = [
|
||||||
icon: Handshake,
|
icon: Handshake,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Forms',
|
name: 'Onboarding',
|
||||||
href: '/admin/forms' as const,
|
href: '/admin/onboarding' as const,
|
||||||
icon: FileText,
|
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
|
// Email Sending Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -634,3 +726,57 @@ export async function verifyEmailConnection(): Promise<boolean> {
|
||||||
return false
|
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 { notionImportRouter } from './notion-import'
|
||||||
import { typeformImportRouter } from './typeform-import'
|
import { typeformImportRouter } from './typeform-import'
|
||||||
import { applicationFormRouter } from './applicationForm'
|
import { applicationFormRouter } from './applicationForm'
|
||||||
|
import { onboardingRouter } from './onboarding'
|
||||||
// Phase 2B routers
|
// Phase 2B routers
|
||||||
import { tagRouter } from './tag'
|
import { tagRouter } from './tag'
|
||||||
import { applicantRouter } from './applicant'
|
import { applicantRouter } from './applicant'
|
||||||
|
|
@ -51,6 +52,7 @@ export const appRouter = router({
|
||||||
notionImport: notionImportRouter,
|
notionImport: notionImportRouter,
|
||||||
typeformImport: typeformImportRouter,
|
typeformImport: typeformImportRouter,
|
||||||
applicationForm: applicationFormRouter,
|
applicationForm: applicationFormRouter,
|
||||||
|
onboarding: onboardingRouter,
|
||||||
// Phase 2B routers
|
// Phase 2B routers
|
||||||
tag: tagRouter,
|
tag: tagRouter,
|
||||||
applicant: applicantRouter,
|
applicant: applicantRouter,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ const fieldTypeEnum = z.enum([
|
||||||
'INSTRUCTIONS',
|
'INSTRUCTIONS',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Special field type enum
|
||||||
|
const specialFieldTypeEnum = z.enum([
|
||||||
|
'TEAM_MEMBERS',
|
||||||
|
'COMPETITION_CATEGORY',
|
||||||
|
'OCEAN_ISSUE',
|
||||||
|
'FILE_UPLOAD',
|
||||||
|
'GDPR_CONSENT',
|
||||||
|
'COUNTRY_SELECT',
|
||||||
|
])
|
||||||
|
|
||||||
// Field input schema
|
// Field input schema
|
||||||
const fieldInputSchema = z.object({
|
const fieldInputSchema = z.object({
|
||||||
fieldType: fieldTypeEnum,
|
fieldType: fieldTypeEnum,
|
||||||
|
|
@ -52,6 +62,25 @@ const fieldInputSchema = z.object({
|
||||||
.optional(),
|
.optional(),
|
||||||
sortOrder: z.number().int().default(0),
|
sortOrder: z.number().int().default(0),
|
||||||
width: z.enum(['full', 'half']).default('full'),
|
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({
|
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
|
get: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
|
|
@ -110,7 +139,12 @@ export const applicationFormRouter = router({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
program: { select: { id: true, name: true, year: true } },
|
program: { select: { id: true, name: true, year: true } },
|
||||||
|
round: { select: { id: true, name: true, slug: true } },
|
||||||
fields: { orderBy: { sortOrder: 'asc' } },
|
fields: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
steps: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { fields: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
_count: { select: { submissions: true } },
|
_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
|
addField: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|
@ -325,19 +359,28 @@ export const applicationFormRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
const maxOrder = await ctx.prisma.applicationFormField.aggregate({
|
||||||
where: { formId: input.formId },
|
where: whereClause,
|
||||||
_max: { sortOrder: true },
|
_max: { sortOrder: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { stepId, projectMapping, specialType, ...restField } = input.field
|
||||||
|
|
||||||
const field = await ctx.prisma.applicationFormField.create({
|
const field = await ctx.prisma.applicationFormField.create({
|
||||||
data: {
|
data: {
|
||||||
formId: input.formId,
|
formId: input.formId,
|
||||||
...input.field,
|
...restField,
|
||||||
sortOrder: input.field.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
|
sortOrder: restField.sortOrder ?? (maxOrder._max.sortOrder ?? 0) + 1,
|
||||||
optionsJson: input.field.optionsJson ?? undefined,
|
optionsJson: restField.optionsJson ?? undefined,
|
||||||
conditionJson: input.field.conditionJson ?? 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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { stepId, projectMapping, specialType, ...restField } = input.field
|
||||||
|
|
||||||
const field = await ctx.prisma.applicationFormField.update({
|
const field = await ctx.prisma.applicationFormField.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
...input.field,
|
...restField,
|
||||||
optionsJson: input.field.optionsJson ?? undefined,
|
optionsJson: restField.optionsJson ?? undefined,
|
||||||
conditionJson: input.field.conditionJson ?? 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
|
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
|
* Get form statistics
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,140 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma, PrismaClient } from '@prisma/client'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
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 { logAudit } from '../utils/audit'
|
||||||
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
|
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({
|
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
|
executeRules: adminProcedure
|
||||||
.input(z.object({ roundId: z.string() }))
|
.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 AnonymizedProjectForAI,
|
||||||
type ProjectAIMapping,
|
type ProjectAIMapping,
|
||||||
} from './anonymization'
|
} 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 ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -410,7 +419,8 @@ export async function executeAIScreening(
|
||||||
config: AIScreeningConfig,
|
config: AIScreeningConfig,
|
||||||
projects: ProjectForFiltering[],
|
projects: ProjectForFiltering[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
entityId?: string
|
entityId?: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
): Promise<Map<string, AIScreeningResult>> {
|
): Promise<Map<string, AIScreeningResult>> {
|
||||||
const results = new Map<string, AIScreeningResult>()
|
const results = new Map<string, AIScreeningResult>()
|
||||||
|
|
||||||
|
|
@ -444,13 +454,15 @@ export async function executeAIScreening(
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalTokens = 0
|
let totalTokens = 0
|
||||||
|
const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE)
|
||||||
|
|
||||||
// Process in batches
|
// Process in batches
|
||||||
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
|
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
|
||||||
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
|
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
|
||||||
const batchMappings = mappings.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(
|
const { results: batchResults, tokensUsed } = await processAIBatch(
|
||||||
openai,
|
openai,
|
||||||
|
|
@ -468,6 +480,16 @@ export async function executeAIScreening(
|
||||||
for (const [id, result] of batchResults) {
|
for (const [id, result] of batchResults) {
|
||||||
results.set(id, result)
|
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}`)
|
console.log(`[AI Filtering] Completed. Total tokens: ${totalTokens}`)
|
||||||
|
|
@ -513,7 +535,8 @@ export async function executeFilteringRules(
|
||||||
rules: FilteringRuleInput[],
|
rules: FilteringRuleInput[],
|
||||||
projects: ProjectForFiltering[],
|
projects: ProjectForFiltering[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
roundId?: string
|
roundId?: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
): Promise<ProjectFilteringResult[]> {
|
): Promise<ProjectFilteringResult[]> {
|
||||||
const activeRules = rules
|
const activeRules = rules
|
||||||
.filter((r) => r.isActive)
|
.filter((r) => r.isActive)
|
||||||
|
|
@ -528,7 +551,7 @@ export async function executeFilteringRules(
|
||||||
|
|
||||||
for (const aiRule of aiRules) {
|
for (const aiRule of aiRules) {
|
||||||
const config = aiRule.configJson as unknown as AIScreeningConfig
|
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)
|
aiResults.set(aiRule.id, screeningResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue