Remove dynamic form builder and complete RoundProject→roundId migration
Build and Push Docker Image / build (push) Successful in 14m3s Details

Major cleanup and schema migration:
- Remove unused dynamic form builder system (ApplicationForm, ApplicationFormField, etc.)
- Complete migration from RoundProject junction table to direct Project.roundId
- Add sortOrder and entryNotificationType fields to Round model
- Add country field to User model for mentor matching
- Enhance onboarding with profile photo and country selection steps
- Fix all TypeScript errors related to roundProjects references
- Remove unused libraries (@radix-ui/react-toast, embla-carousel-react, vaul)

Files removed:
- admin/forms/* pages and related components
- admin/onboarding/* pages
- applicationForm.ts and onboarding.ts routers
- Dynamic form builder Prisma models and enums

Schema changes:
- Removed ApplicationForm, ApplicationFormField, OnboardingStep, ApplicationFormSubmission, SubmissionFile models
- Removed FormFieldType and SpecialFieldType enums
- Added Round.sortOrder, Round.entryNotificationType
- Added User.country

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

78
package-lock.json generated
View File

@ -37,7 +37,6 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
@ -50,7 +49,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"minio": "^8.0.2", "minio": "^8.0.2",
@ -73,7 +71,6 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"twilio": "^5.4.0", "twilio": "^5.4.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
@ -3072,40 +3069,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-toast": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@ -6586,34 +6549,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-mart": { "node_modules/emoji-mart": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
@ -13601,19 +13536,6 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@ -50,7 +50,6 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
@ -63,7 +62,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"minio": "^8.0.2", "minio": "^8.0.2",
@ -86,7 +84,6 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"twilio": "^5.4.0", "twilio": "^5.4.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -3,28 +3,28 @@ import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function check() { async function check() {
const projects = await prisma.project.count() const projectCount = await prisma.project.count()
console.log('Total projects:', projects) console.log('Total projects:', projectCount)
const rounds = await prisma.round.findMany({ const rounds = await prisma.round.findMany({
include: { include: {
_count: { select: { roundProjects: true } } _count: { select: { projects: true } }
} }
}) })
for (const r of rounds) { for (const r of rounds) {
console.log(`Round: ${r.name} (id: ${r.id})`) console.log(`Round: ${r.name} (id: ${r.id})`)
console.log(` Projects: ${r._count.roundProjects}`) console.log(` Projects: ${r._count.projects}`)
} }
// Check if projects have programId set // Check sample projects with their round
const sampleProjects = await prisma.project.findMany({ const sampleProjects = await prisma.project.findMany({
select: { id: true, title: true, programId: true }, select: { id: true, title: true, roundId: true },
take: 5 take: 5
}) })
console.log('\nSample projects:') console.log('\nSample projects:')
for (const p of sampleProjects) { for (const p of sampleProjects) {
console.log(` ${p.title}: programId=${p.programId}`) console.log(` ${p.title}: roundId=${p.roundId}`)
} }
} }

View File

@ -10,18 +10,18 @@ async function cleanup() {
id: true, id: true,
name: true, name: true,
slug: true, slug: true,
roundProjects: { select: { id: true, projectId: true, project: { select: { id: true, title: true } } } }, projects: { select: { id: true, title: true } },
_count: { select: { roundProjects: true } } _count: { select: { projects: true } }
} }
}) })
console.log(`Found ${rounds.length} rounds:`) console.log(`Found ${rounds.length} rounds:`)
for (const round of rounds) { for (const round of rounds) {
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.roundProjects} projects`) console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`)
} }
// Find rounds with 9 or fewer projects (dummy data) // Find rounds with 9 or fewer projects (dummy data)
const dummyRounds = rounds.filter(r => r._count.roundProjects <= 9) const dummyRounds = rounds.filter(r => r._count.projects <= 9)
if (dummyRounds.length > 0) { if (dummyRounds.length > 0) {
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`) console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
@ -29,15 +29,9 @@ async function cleanup() {
for (const round of dummyRounds) { for (const round of dummyRounds) {
console.log(`\nProcessing: ${round.name}`) console.log(`\nProcessing: ${round.name}`)
const projectIds = round.roundProjects.map(rp => rp.projectId) const projectIds = round.projects.map(p => p.id)
if (projectIds.length > 0) { if (projectIds.length > 0) {
// Delete round-project associations first
const rpDeleted = await prisma.roundProject.deleteMany({
where: { roundId: round.id }
})
console.log(` Deleted ${rpDeleted.count} round-project associations`)
// Delete team members // Delete team members
const teamDeleted = await prisma.teamMember.deleteMany({ const teamDeleted = await prisma.teamMember.deleteMany({
where: { projectId: { in: projectIds } } where: { projectId: { in: projectIds } }

View File

@ -8,15 +8,15 @@ async function cleanup() {
// Find and delete the dummy round // Find and delete the dummy round
const dummyRound = await prisma.round.findFirst({ const dummyRound = await prisma.round.findFirst({
where: { slug: 'round-1-2026' }, where: { slug: 'round-1-2026' },
include: { roundProjects: { include: { project: true } } } include: { projects: true }
}) })
if (dummyRound) { if (dummyRound) {
console.log(`Found dummy round: ${dummyRound.name}`) console.log(`Found dummy round: ${dummyRound.name}`)
console.log(`Projects in round: ${dummyRound.roundProjects.length}`) console.log(`Projects in round: ${dummyRound.projects.length}`)
// Get project IDs to delete // Get project IDs to delete
const projectIds = dummyRound.roundProjects.map(rp => rp.projectId) const projectIds = dummyRound.projects.map(p => p.id)
// Delete team members for these projects // Delete team members for these projects
if (projectIds.length > 0) { if (projectIds.length > 0) {
@ -25,12 +25,6 @@ async function cleanup() {
}) })
console.log(`Deleted ${teamDeleted.count} team members`) console.log(`Deleted ${teamDeleted.count} team members`)
// Delete round-project associations
await prisma.roundProject.deleteMany({
where: { roundId: dummyRound.id }
})
console.log(`Deleted round-project associations`)
// Delete the projects // Delete the projects
const projDeleted = await prisma.project.deleteMany({ const projDeleted = await prisma.project.deleteMany({
where: { id: { in: projectIds } } where: { id: { in: projectIds } }

View File

@ -147,35 +147,6 @@ enum PartnerType {
OTHER OTHER
} }
enum FormFieldType {
TEXT
TEXTAREA
NUMBER
EMAIL
PHONE
URL
DATE
DATETIME
SELECT
MULTI_SELECT
RADIO
CHECKBOX
CHECKBOX_GROUP
FILE
FILE_MULTIPLE
SECTION
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
// ============================================================================= // =============================================================================
@ -225,6 +196,7 @@ model User {
status UserStatus @default(INVITED) status UserStatus @default(INVITED)
expertiseTags String[] @default([]) expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit maxAssignments Int? // Per-round limit
country String? // User's home country (for mentor matching)
metadataJson Json? @db.JsonB metadataJson Json? @db.JsonB
// Profile image // Profile image
@ -348,10 +320,8 @@ model Program {
// Relations // Relations
rounds Round[] rounds Round[]
projects Project[]
learningResources LearningResource[] learningResources LearningResource[]
partners Partner[] partners Partner[]
applicationForms ApplicationForm[]
specialAwards SpecialAward[] specialAwards SpecialAward[]
@@unique([name, year]) @@unique([name, year])
@ -365,7 +335,10 @@ model Round {
slug String? @unique // URL-friendly identifier for public submissions slug String? @unique // URL-friendly identifier for public submissions
status RoundStatus @default(DRAFT) status RoundStatus @default(DRAFT)
roundType RoundType @default(EVALUATION) roundType RoundType @default(EVALUATION)
sortOrder Int @default(0) // Progression order within program sortOrder Int @default(0) // Display order within program
// Entry notification settings
entryNotificationType String? // Type of notification to send when project enters round
// Submission window (for applicant portal) // Submission window (for applicant portal)
submissionDeadline DateTime? // Deadline for project submissions submissionDeadline DateTime? // Deadline for project submissions
@ -385,15 +358,12 @@ model Round {
requiredReviews Int @default(3) // Min evaluations per project requiredReviews Int @default(3) // Min evaluations per project
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc. settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
// Notification sent to project team when they enter this round
entryNotificationType String? // e.g., "ADVANCED_SEMIFINAL", "ADVANCED_FINAL"
createdAt DateTime @default(now()) createdAt DateTime @default(now())
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[] projects Project[]
assignments Assignment[] assignments Assignment[]
evaluationForms EvaluationForm[] evaluationForms EvaluationForm[]
gracePeriods GracePeriod[] gracePeriods GracePeriod[]
@ -401,7 +371,6 @@ model Round {
filteringRules FilteringRule[] filteringRules FilteringRule[]
filteringResults FilteringResult[] filteringResults FilteringResult[]
filteringJobs FilteringJob[] filteringJobs FilteringJob[]
applicationForm ApplicationForm?
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@ -439,7 +408,8 @@ model EvaluationForm {
model Project { model Project {
id String @id @default(cuid()) id String @id @default(cuid())
programId String roundId String
status ProjectStatus @default(SUBMITTED)
// Core fields // Core fields
title String title String
@ -493,8 +463,7 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade) round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
roundProjects RoundProject[]
files ProjectFile[] files ProjectFile[]
assignments Assignment[] assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull) submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
@ -504,8 +473,10 @@ model Project {
awardEligibilities AwardEligibility[] awardEligibilities AwardEligibility[]
awardVotes AwardVote[] awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner") wonAwards SpecialAward[] @relation("AwardWinner")
projectTags ProjectTag[]
@@index([programId]) @@index([roundId])
@@index([status])
@@index([tags]) @@index([tags])
@@index([submissionSource]) @@index([submissionSource])
@@index([submittedByUserId]) @@index([submittedByUserId])
@ -514,23 +485,6 @@ model Project {
@@index([country]) @@index([country])
} }
model RoundProject {
id String @id @default(cuid())
roundId String
projectId String
status ProjectStatus @default(SUBMITTED)
addedAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([roundId, projectId])
@@index([roundId])
@@index([projectId])
@@index([status])
}
model ProjectFile { model ProjectFile {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
@ -906,149 +860,6 @@ model Partner {
@@index([sortOrder]) @@index([sortOrder])
} }
// =============================================================================
// APPLICATION FORMS (Phase 2)
// =============================================================================
model ApplicationForm {
id String @id @default(cuid())
programId String? // null = global form
name String
description String? @db.Text
status String @default("DRAFT") // DRAFT, PUBLISHED, CLOSED
isPublic Boolean @default(false)
publicSlug String? @unique // /apply/ocean-challenge-2026
submissionLimit Int?
opensAt DateTime?
closesAt DateTime?
confirmationMessage String? @db.Text
// Round linking (for onboarding forms that create projects)
roundId String? @unique
// Email settings
sendConfirmationEmail Boolean @default(true)
sendTeamInviteEmails Boolean @default(true)
confirmationEmailSubject String?
confirmationEmailBody String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
fields ApplicationFormField[]
steps OnboardingStep[]
submissions ApplicationFormSubmission[]
@@index([programId])
@@index([status])
@@index([isPublic])
@@index([roundId])
}
model ApplicationFormField {
id String @id @default(cuid())
formId String
stepId String? // Which step this field belongs to (for onboarding)
fieldType FormFieldType
name String // Internal name (e.g., "project_title")
label String // Display label (e.g., "Project Title")
description String? @db.Text
placeholder String?
required Boolean @default(false)
minLength Int?
maxLength Int?
minValue Float? // For NUMBER type
maxValue Float? // For NUMBER type
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
// Onboarding-specific fields
projectMapping String? // Maps to Project column: "title", "description", etc.
specialType SpecialFieldType? // Special handling for complex fields
sortOrder Int @default(0)
width String @default("full") // full, half
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
step OnboardingStep? @relation(fields: [stepId], references: [id], onDelete: SetNull)
@@index([formId])
@@index([stepId])
@@index([sortOrder])
}
model OnboardingStep {
id String @id @default(cuid())
formId String
name String // Internal identifier (e.g., "category", "contact")
title String // Display title (e.g., "Category", "Contact Information")
description String? @db.Text
sortOrder Int @default(0)
isOptional Boolean @default(false)
conditionJson Json? @db.JsonB // Conditional visibility: { fieldId, operator, value }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
fields ApplicationFormField[]
@@index([formId])
@@index([sortOrder])
}
model ApplicationFormSubmission {
id String @id @default(cuid())
formId String
email String?
name String?
dataJson Json @db.JsonB // Field values: { fieldName: value, ... }
status String @default("SUBMITTED") // SUBMITTED, REVIEWED, APPROVED, REJECTED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
files SubmissionFile[]
@@index([formId])
@@index([status])
@@index([email])
@@index([createdAt])
}
model SubmissionFile {
id String @id @default(cuid())
submissionId String
fieldName String
fileName String
mimeType String?
size Int?
bucket String
objectKey String
createdAt DateTime @default(now())
// Relations
submission ApplicationFormSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
@@index([submissionId])
@@unique([bucket, objectKey])
}
// ============================================================================= // =============================================================================
// EXPERTISE TAGS (Phase 2B) // EXPERTISE TAGS (Phase 2B)
// ============================================================================= // =============================================================================
@ -1065,11 +876,32 @@ model ExpertiseTag {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations
projectTags ProjectTag[]
@@index([category]) @@index([category])
@@index([isActive]) @@index([isActive])
@@index([sortOrder]) @@index([sortOrder])
} }
// Project-Tag relationship for AI tagging
model ProjectTag {
id String @id @default(cuid())
projectId String
tagId String
confidence Float @default(1.0) // AI confidence score 0-1
source String @default("AI") // "AI" or "MANUAL"
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tag ExpertiseTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([projectId, tagId])
@@index([projectId])
@@index([tagId])
}
// ============================================================================= // =============================================================================
// LIVE VOTING (Phase 2B) // LIVE VOTING (Phase 2B)
// ============================================================================= // =============================================================================

View File

@ -321,7 +321,7 @@ async function main() {
// Check if project already exists // Check if project already exists
const existingProject = await prisma.project.findFirst({ const existingProject = await prisma.project.findFirst({
where: { where: {
programId: program.id, roundId: round.id,
OR: [ OR: [
{ title: projectName }, { title: projectName },
{ submittedByEmail: email }, { submittedByEmail: email },
@ -365,7 +365,7 @@ async function main() {
// Create project // Create project
const project = await prisma.project.create({ const project = await prisma.project.create({
data: { data: {
programId: program.id, roundId: round.id,
title: projectName, title: projectName,
description: row['Comment ']?.trim() || null, description: row['Comment ']?.trim() || null,
competitionCategory: mapCategory(row['Category']), competitionCategory: mapCategory(row['Category']),
@ -391,15 +391,6 @@ async function main() {
}, },
}) })
// Create round-project association
await prisma.roundProject.create({
data: {
roundId: round.id,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Create team lead membership // Create team lead membership
await prisma.teamMember.create({ await prisma.teamMember.create({
data: { data: {
@ -474,7 +465,7 @@ async function main() {
console.log('\nBackfilling missing country codes...\n') console.log('\nBackfilling missing country codes...\n')
let backfilled = 0 let backfilled = 0
const nullCountryProjects = await prisma.project.findMany({ const nullCountryProjects = await prisma.project.findMany({
where: { programId: program.id, country: null }, where: { roundId: round.id, country: null },
select: { id: true, submittedByEmail: true, title: true }, select: { id: true, submittedByEmail: true, title: true },
}) })

View File

@ -64,14 +64,13 @@ async function main() {
console.log(`Voting window: ${votingStart.toISOString()}${votingEnd.toISOString()}\n`) console.log(`Voting window: ${votingStart.toISOString()}${votingEnd.toISOString()}\n`)
// Get some projects to assign (via RoundProject) // Get some projects to assign
const roundProjects = await prisma.roundProject.findMany({ const projects = await prisma.project.findMany({
where: { roundId: round.id }, where: { roundId: round.id },
take: 8, take: 8,
orderBy: { addedAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { project: { select: { id: true, title: true } } }, select: { id: true, title: true },
}) })
const projects = roundProjects.map(rp => rp.project)
if (projects.length === 0) { if (projects.length === 0) {
console.error('No projects found! Run seed-candidatures first.') console.error('No projects found! Run seed-candidatures first.')

View File

@ -1,270 +0,0 @@
/**
* Seed script for MOPC Onboarding Form (ESM version for production)
* Run with: node prisma/seed-mopc-onboarding.mjs
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const MOPC_FORM_CONFIG = {
name: 'MOPC Application 2026',
description: 'Monaco Ocean Protection Challenge application form',
publicSlug: 'mopc-2026',
status: 'PUBLISHED',
isPublic: true,
sendConfirmationEmail: true,
sendTeamInviteEmails: true,
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
We have received your application and our team will review it carefully.
If you have any questions, please don't hesitate to reach out.
Good luck!
The MOPC Team`,
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
}
const STEPS = [
{
name: 'category',
title: 'Competition Category',
description: 'Select your competition track',
sortOrder: 0,
isOptional: false,
fields: [
{
name: 'competitionCategory',
label: 'Which category best describes your project?',
fieldType: 'RADIO',
specialType: 'COMPETITION_CATEGORY',
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'competitionCategory',
description: 'Choose the category that best fits your stage of development',
optionsJson: [
{ value: 'STARTUP', label: 'Startup', description: 'You have an existing company or registered business entity' },
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept', description: 'You are a student, graduate, or have an idea not yet incorporated' },
],
},
],
},
{
name: 'contact',
title: 'Contact Information',
description: 'Tell us how to reach you',
sortOrder: 1,
isOptional: false,
fields: [
{ name: 'contactName', label: 'Full Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'half', placeholder: 'Enter your full name' },
{ name: 'contactEmail', label: 'Email Address', fieldType: 'EMAIL', required: true, sortOrder: 1, width: 'half', placeholder: 'your.email@example.com', description: 'We will use this email for all communications' },
{ name: 'contactPhone', label: 'Phone Number', fieldType: 'PHONE', required: true, sortOrder: 2, width: 'half', placeholder: '+1 (555) 123-4567' },
{ name: 'country', label: 'Country', fieldType: 'SELECT', specialType: 'COUNTRY_SELECT', required: true, sortOrder: 3, width: 'half', projectMapping: 'country' },
{ name: 'city', label: 'City', fieldType: 'TEXT', required: false, sortOrder: 4, width: 'half', placeholder: 'City name' },
],
},
{
name: 'project',
title: 'Project Details',
description: 'Tell us about your ocean protection project',
sortOrder: 2,
isOptional: false,
fields: [
{ name: 'projectName', label: 'Project Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'full', projectMapping: 'title', maxLength: 200, placeholder: 'Give your project a memorable name' },
{ name: 'teamName', label: 'Team / Company Name', fieldType: 'TEXT', required: false, sortOrder: 1, width: 'half', projectMapping: 'teamName', placeholder: 'Your team or company name' },
{ name: 'oceanIssue', label: 'Primary Ocean Issue', fieldType: 'SELECT', specialType: 'OCEAN_ISSUE', required: true, sortOrder: 2, width: 'half', projectMapping: 'oceanIssue', description: 'Select the primary ocean issue your project addresses' },
{ name: 'description', label: 'Project Description', fieldType: 'TEXTAREA', required: true, sortOrder: 3, width: 'full', projectMapping: 'description', minLength: 50, maxLength: 2000, placeholder: 'Describe your project, its goals, and how it will help protect the ocean...', description: 'Provide a clear description of your project (50-2000 characters)' },
{ name: 'websiteUrl', label: 'Website URL', fieldType: 'URL', required: false, sortOrder: 4, width: 'half', projectMapping: 'websiteUrl', placeholder: 'https://yourproject.com' },
],
},
{
name: 'team',
title: 'Team Members',
description: 'Add your team members (they will receive email invitations)',
sortOrder: 3,
isOptional: true,
fields: [
{ name: 'teamMembers', label: 'Team Members', fieldType: 'TEXT', specialType: 'TEAM_MEMBERS', required: false, sortOrder: 0, width: 'full', description: 'Add up to 5 team members. They will receive an invitation email to join your application.' },
],
},
{
name: 'additional',
title: 'Additional Details',
description: 'A few more questions about your project',
sortOrder: 4,
isOptional: false,
fields: [
{ name: 'institution', label: 'University / School', fieldType: 'TEXT', required: false, sortOrder: 0, width: 'half', projectMapping: 'institution', placeholder: 'Name of your institution', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'BUSINESS_CONCEPT' } },
{ name: 'startupCreatedDate', label: 'Startup Founded Date', fieldType: 'DATE', required: false, sortOrder: 1, width: 'half', description: 'When was your company founded?', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'STARTUP' } },
{ name: 'wantsMentorship', label: 'I am interested in receiving mentorship', fieldType: 'CHECKBOX', required: false, sortOrder: 2, width: 'full', projectMapping: 'wantsMentorship', description: 'Check this box if you would like to be paired with an expert mentor' },
{ name: 'referralSource', label: 'How did you hear about MOPC?', fieldType: 'SELECT', required: false, sortOrder: 3, width: 'half', optionsJson: [
{ value: 'social_media', label: 'Social Media' },
{ value: 'search_engine', label: 'Search Engine' },
{ value: 'word_of_mouth', label: 'Word of Mouth' },
{ value: 'university', label: 'University / School' },
{ value: 'partner', label: 'Partner Organization' },
{ value: 'media', label: 'News / Media' },
{ value: 'event', label: 'Event / Conference' },
{ value: 'other', label: 'Other' },
]},
],
},
{
name: 'review',
title: 'Review & Submit',
description: 'Review your application and accept the terms',
sortOrder: 5,
isOptional: false,
fields: [
{ name: 'instructions', label: 'Review Instructions', fieldType: 'INSTRUCTIONS', required: false, sortOrder: 0, width: 'full', description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.' },
{ name: 'gdprConsent', label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy', fieldType: 'CHECKBOX', specialType: 'GDPR_CONSENT', required: true, sortOrder: 1, width: 'full' },
{ name: 'termsAccepted', label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge', fieldType: 'CHECKBOX', required: true, sortOrder: 2, width: 'full' },
],
},
]
async function main() {
console.log('Seeding MOPC onboarding form...')
// Check if form already exists
const existingForm = await prisma.applicationForm.findUnique({
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
})
if (existingForm) {
console.log('Form with slug "mopc-2026" already exists. Updating...')
// Delete existing steps and fields to recreate them
await prisma.applicationFormField.deleteMany({ where: { formId: existingForm.id } })
await prisma.onboardingStep.deleteMany({ where: { formId: existingForm.id } })
// Update the form
await prisma.applicationForm.update({
where: { id: existingForm.id },
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: existingForm.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: existingForm.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nForm updated: ${existingForm.id}`)
return
}
// Create new form
const form = await prisma.applicationForm.create({
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
publicSlug: MOPC_FORM_CONFIG.publicSlug,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
console.log(`Created form: ${form.id}`)
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: form.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: form.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nMOPC form seeded successfully!`)
console.log(`Form ID: ${form.id}`)
console.log(`Public URL: /apply/${form.publicSlug}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -1,456 +0,0 @@
/**
* Seed script for MOPC Onboarding Form
*
* This creates the application form configuration for the Monaco Ocean Protection Challenge.
* The form is accessible at /apply/mopc-2026
*
* Run with: npx tsx prisma/seed-mopc-onboarding.ts
*/
import { PrismaClient, FormFieldType, SpecialFieldType } from '@prisma/client'
const prisma = new PrismaClient()
const MOPC_FORM_CONFIG = {
name: 'MOPC Application 2026',
description: 'Monaco Ocean Protection Challenge application form',
publicSlug: 'mopc-2026',
status: 'PUBLISHED',
isPublic: true,
sendConfirmationEmail: true,
sendTeamInviteEmails: true,
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
We have received your application and our team will review it carefully.
If you have any questions, please don't hesitate to reach out.
Good luck!
The MOPC Team`,
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
}
const STEPS = [
{
name: 'category',
title: 'Competition Category',
description: 'Select your competition track',
sortOrder: 0,
isOptional: false,
fields: [
{
name: 'competitionCategory',
label: 'Which category best describes your project?',
fieldType: FormFieldType.RADIO,
specialType: SpecialFieldType.COMPETITION_CATEGORY,
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'competitionCategory',
description: 'Choose the category that best fits your stage of development',
optionsJson: [
{
value: 'STARTUP',
label: 'Startup',
description: 'You have an existing company or registered business entity',
},
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concept',
description: 'You are a student, graduate, or have an idea not yet incorporated',
},
],
},
],
},
{
name: 'contact',
title: 'Contact Information',
description: 'Tell us how to reach you',
sortOrder: 1,
isOptional: false,
fields: [
{
name: 'contactName',
label: 'Full Name',
fieldType: FormFieldType.TEXT,
required: true,
sortOrder: 0,
width: 'half',
placeholder: 'Enter your full name',
},
{
name: 'contactEmail',
label: 'Email Address',
fieldType: FormFieldType.EMAIL,
required: true,
sortOrder: 1,
width: 'half',
placeholder: 'your.email@example.com',
description: 'We will use this email for all communications',
},
{
name: 'contactPhone',
label: 'Phone Number',
fieldType: FormFieldType.PHONE,
required: true,
sortOrder: 2,
width: 'half',
placeholder: '+1 (555) 123-4567',
},
{
name: 'country',
label: 'Country',
fieldType: FormFieldType.SELECT,
specialType: SpecialFieldType.COUNTRY_SELECT,
required: true,
sortOrder: 3,
width: 'half',
projectMapping: 'country',
},
{
name: 'city',
label: 'City',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 4,
width: 'half',
placeholder: 'City name',
},
],
},
{
name: 'project',
title: 'Project Details',
description: 'Tell us about your ocean protection project',
sortOrder: 2,
isOptional: false,
fields: [
{
name: 'projectName',
label: 'Project Name',
fieldType: FormFieldType.TEXT,
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'title',
maxLength: 200,
placeholder: 'Give your project a memorable name',
},
{
name: 'teamName',
label: 'Team / Company Name',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 1,
width: 'half',
projectMapping: 'teamName',
placeholder: 'Your team or company name',
},
{
name: 'oceanIssue',
label: 'Primary Ocean Issue',
fieldType: FormFieldType.SELECT,
specialType: SpecialFieldType.OCEAN_ISSUE,
required: true,
sortOrder: 2,
width: 'half',
projectMapping: 'oceanIssue',
description: 'Select the primary ocean issue your project addresses',
},
{
name: 'description',
label: 'Project Description',
fieldType: FormFieldType.TEXTAREA,
required: true,
sortOrder: 3,
width: 'full',
projectMapping: 'description',
minLength: 50,
maxLength: 2000,
placeholder: 'Describe your project, its goals, and how it will help protect the ocean...',
description: 'Provide a clear description of your project (50-2000 characters)',
},
{
name: 'websiteUrl',
label: 'Website URL',
fieldType: FormFieldType.URL,
required: false,
sortOrder: 4,
width: 'half',
projectMapping: 'websiteUrl',
placeholder: 'https://yourproject.com',
},
],
},
{
name: 'team',
title: 'Team Members',
description: 'Add your team members (they will receive email invitations)',
sortOrder: 3,
isOptional: true,
fields: [
{
name: 'teamMembers',
label: 'Team Members',
fieldType: FormFieldType.TEXT, // Will use specialType for rendering
specialType: SpecialFieldType.TEAM_MEMBERS,
required: false,
sortOrder: 0,
width: 'full',
description: 'Add up to 5 team members. They will receive an invitation email to join your application.',
},
],
},
{
name: 'additional',
title: 'Additional Details',
description: 'A few more questions about your project',
sortOrder: 4,
isOptional: false,
fields: [
{
name: 'institution',
label: 'University / School',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 0,
width: 'half',
projectMapping: 'institution',
placeholder: 'Name of your institution',
conditionJson: {
field: 'competitionCategory',
operator: 'equals',
value: 'BUSINESS_CONCEPT',
},
},
{
name: 'startupCreatedDate',
label: 'Startup Founded Date',
fieldType: FormFieldType.DATE,
required: false,
sortOrder: 1,
width: 'half',
description: 'When was your company founded?',
conditionJson: {
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
},
{
name: 'wantsMentorship',
label: 'I am interested in receiving mentorship',
fieldType: FormFieldType.CHECKBOX,
required: false,
sortOrder: 2,
width: 'full',
projectMapping: 'wantsMentorship',
description: 'Check this box if you would like to be paired with an expert mentor',
},
{
name: 'referralSource',
label: 'How did you hear about MOPC?',
fieldType: FormFieldType.SELECT,
required: false,
sortOrder: 3,
width: 'half',
optionsJson: [
{ value: 'social_media', label: 'Social Media' },
{ value: 'search_engine', label: 'Search Engine' },
{ value: 'word_of_mouth', label: 'Word of Mouth' },
{ value: 'university', label: 'University / School' },
{ value: 'partner', label: 'Partner Organization' },
{ value: 'media', label: 'News / Media' },
{ value: 'event', label: 'Event / Conference' },
{ value: 'other', label: 'Other' },
],
},
],
},
{
name: 'review',
title: 'Review & Submit',
description: 'Review your application and accept the terms',
sortOrder: 5,
isOptional: false,
fields: [
{
name: 'instructions',
label: 'Review Instructions',
fieldType: FormFieldType.INSTRUCTIONS,
required: false,
sortOrder: 0,
width: 'full',
description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.',
},
{
name: 'gdprConsent',
label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy',
fieldType: FormFieldType.CHECKBOX,
specialType: SpecialFieldType.GDPR_CONSENT,
required: true,
sortOrder: 1,
width: 'full',
},
{
name: 'termsAccepted',
label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge',
fieldType: FormFieldType.CHECKBOX,
required: true,
sortOrder: 2,
width: 'full',
},
],
},
]
async function main() {
console.log('Seeding MOPC onboarding form...')
// Check if form already exists
const existingForm = await prisma.applicationForm.findUnique({
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
})
if (existingForm) {
console.log('Form with slug "mopc-2026" already exists. Updating...')
// Delete existing steps and fields to recreate them
await prisma.applicationFormField.deleteMany({
where: { formId: existingForm.id },
})
await prisma.onboardingStep.deleteMany({
where: { formId: existingForm.id },
})
// Update the form
await prisma.applicationForm.update({
where: { id: existingForm.id },
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: existingForm.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const fieldData of stepData.fields) {
const field = fieldData as Record<string, unknown>
await prisma.applicationFormField.create({
data: {
formId: existingForm.id,
stepId: step.id,
name: field.name as string,
label: field.label as string,
fieldType: field.fieldType as FormFieldType,
specialType: (field.specialType as SpecialFieldType) || null,
required: field.required as boolean,
sortOrder: field.sortOrder as number,
width: field.width as string,
description: (field.description as string) || null,
placeholder: (field.placeholder as string) || null,
projectMapping: (field.projectMapping as string) || null,
minLength: (field.minLength as number) || null,
maxLength: (field.maxLength as number) || null,
optionsJson: field.optionsJson as object | undefined,
conditionJson: field.conditionJson as object | undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nForm updated: ${existingForm.id}`)
return
}
// Create new form
const form = await prisma.applicationForm.create({
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
publicSlug: MOPC_FORM_CONFIG.publicSlug,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
console.log(`Created form: ${form.id}`)
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: form.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const fieldData of stepData.fields) {
const field = fieldData as Record<string, unknown>
await prisma.applicationFormField.create({
data: {
formId: form.id,
stepId: step.id,
name: field.name as string,
label: field.label as string,
fieldType: field.fieldType as FormFieldType,
specialType: (field.specialType as SpecialFieldType) || null,
required: field.required as boolean,
sortOrder: field.sortOrder as number,
width: field.width as string,
description: (field.description as string) || null,
placeholder: (field.placeholder as string) || null,
projectMapping: (field.projectMapping as string) || null,
minLength: (field.minLength as number) || null,
maxLength: (field.maxLength as number) || null,
optionsJson: field.optionsJson as object | undefined,
conditionJson: field.conditionJson as object | undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nMOPC form seeded successfully!`)
console.log(`Form ID: ${form.id}`)
console.log(`Public URL: /apply/${form.publicSlug}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -1,241 +0,0 @@
'use client'
import { useState } from 'react'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2, Plus, Trash2, GripVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
interface FormEditorProps {
form: {
id: string
name: string
description: string | null
status: string
isPublic: boolean
publicSlug: string | null
submissionLimit: number | null
opensAt: Date | null
closesAt: Date | null
confirmationMessage: string | null
fields: Array<{
id: string
fieldType: string
name: string
label: string
description: string | null
placeholder: string | null
required: boolean
sortOrder: number
}>
}
}
export function FormEditor({ form }: FormEditorProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: form.name,
description: form.description || '',
status: form.status,
isPublic: form.isPublic,
publicSlug: form.publicSlug || '',
confirmationMessage: form.confirmationMessage || '',
})
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
toast.success('Form updated successfully')
router.refresh()
setIsSubmitting(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to update form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateForm.mutate({
id: form.id,
name: formData.name,
status: formData.status as 'DRAFT' | 'PUBLISHED' | 'CLOSED',
isPublic: formData.isPublic,
description: formData.description || null,
publicSlug: formData.publicSlug || null,
confirmationMessage: formData.confirmationMessage || null,
})
}
return (
<Tabs defaultValue="settings" className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="fields">Fields ({form.fields.length})</TabsTrigger>
</TabsList>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>
Configure the basic settings for this form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Form Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
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"
value={formData.publicSlug}
onChange={(e) => setFormData({ ...formData, publicSlug: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
<Label htmlFor="isPublic">Public form (accessible without login)</Label>
</div>
<div className="space-y-2">
<Label htmlFor="confirmationMessage">Confirmation Message</Label>
<Textarea
id="confirmationMessage"
value={formData.confirmationMessage}
onChange={(e) => setFormData({ ...formData, confirmationMessage: e.target.value })}
placeholder="Thank you for your submission..."
rows={3}
maxLength={1000}
/>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Settings
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="fields">
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
<CardDescription>
Add and arrange the fields for your application form
</CardDescription>
</CardHeader>
<CardContent>
{form.fields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No fields added yet.</p>
<p className="text-sm">Add fields to start building your form.</p>
</div>
) : (
<div className="space-y-2">
{form.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 border rounded-lg"
>
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<div className="flex-1">
<div className="font-medium">{field.label}</div>
<div className="text-sm text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</div>
</div>
<Button variant="ghost" size="icon" aria-label="Delete field">
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="mt-4">
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@ -1,83 +0,0 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Settings, Eye, FileText, Plus } from 'lucide-react'
import { FormEditor } from './form-editor'
interface FormDetailPageProps {
params: Promise<{ id: string }>
}
export default async function FormDetailPage({ params }: FormDetailPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{form.name}</h1>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<p className="text-muted-foreground">
{form.fields.length} fields - {form._count.submissions} submissions
</p>
</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>
)}
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
Submissions
</Button>
</Link>
</div>
</div>
<FormEditor form={form} />
</div>
)
}

View File

@ -1,130 +0,0 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Download, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionDetailPageProps {
params: Promise<{ id: string; submissionId: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id, submissionId } = await params
const caller = await api()
let submission
try {
submission = await caller.applicationForm.getSubmission({ id: submissionId })
} catch {
notFound()
}
const data = submission.dataJson as Record<string, unknown>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{submission.name || submission.email || 'Anonymous Submission'}
</h1>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-muted-foreground">
Submitted {formatDate(submission.createdAt)}
</p>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Submission Data</CardTitle>
<CardDescription>
All fields submitted in this application
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{submission.form.fields.map((field) => {
const value = data[field.name]
return (
<div key={field.id} className="border-b pb-4 last:border-0">
<div className="font-medium text-sm text-muted-foreground">
{field.label}
</div>
<div className="mt-1">
{value !== undefined && value !== null && value !== '' ? (
typeof value === 'object' ? (
<pre className="text-sm bg-muted p-2 rounded">
{JSON.stringify(value, null, 2)}
</pre>
) : (
<span>{String(value)}</span>
)
) : (
<span className="text-muted-foreground italic">Not provided</span>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{submission.files.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Attached Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{submission.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<div className="font-medium">{file.fileName}</div>
<div className="text-sm text-muted-foreground">
{file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size'}
</div>
</div>
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -1,135 +0,0 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
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 { ArrowLeft, Inbox, Eye, Download } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionsPageProps {
params: Promise<{ id: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
async function SubmissionsList({ formId }: { formId: string }) {
const caller = await api()
const { data: submissions } = await caller.applicationForm.listSubmissions({
formId,
perPage: 50,
})
if (submissions.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Inbox className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No submissions yet</h3>
<p className="text-muted-foreground">
Submissions will appear here once people start filling out the form
</p>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{submissions.map((submission) => (
<Card key={submission.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium">
{submission.name || submission.email || 'Anonymous'}
</h3>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{submission.email && <span>{submission.email} - </span>}
Submitted {formatDate(submission.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/forms/${formId}/submissions/${submission.id}`}>
<Button variant="ghost" size="icon">
<Eye 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">
<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 async function SubmissionsPage({ params }: SubmissionsPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Submissions</h1>
<p className="text-muted-foreground">
{form.name} - {form._count.submissions} total submissions
</p>
</div>
</div>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<SubmissionsList formId={id} />
</Suspense>
</div>
)
}

View File

@ -1,131 +0,0 @@
'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 { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Form created successfully')
router.push(`/admin/forms/${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: null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Application Form</h1>
<p className="text-muted-foreground">
Set up a new application form
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Basic information about your application 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., 2024 Project Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this 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., 2024-applications"
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/forms">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,152 +0,0 @@
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,
Copy,
MoreHorizontal,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
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 FormsList() {
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 forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application form
</p>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create 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>
</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/forms/${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 FormsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Application Forms</h1>
<p className="text-muted-foreground">
Create and manage custom application forms
</p>
</div>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<FormsList />
</Suspense>
</div>
)
}

View File

@ -329,7 +329,7 @@ export default function MemberDetailPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary"> <Badge variant="secondary">
{assignment.project.roundProjects?.[0]?.status ?? 'SUBMITTED'} {assignment.project.status ?? 'SUBMITTED'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">

View File

@ -31,6 +31,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@ -42,12 +48,19 @@ import {
Plus, Plus,
FileSpreadsheet, FileSpreadsheet,
UserPlus, UserPlus,
FolderKanban,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete' type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
roundId: string
}
interface MemberRow { interface MemberRow {
id: string id: string
name: string name: string
@ -55,6 +68,7 @@ interface MemberRow {
role: Role role: Role
expertiseTags: string[] expertiseTags: string[]
tagInput: string tagInput: string
assignments: Assignment[]
} }
interface ParsedUser { interface ParsedUser {
@ -62,6 +76,7 @@ interface ParsedUser {
name?: string name?: string
role: Role role: Role
expertiseTags?: string[] expertiseTags?: string[]
assignments?: Assignment[]
isValid: boolean isValid: boolean
error?: string error?: string
isDuplicate?: boolean isDuplicate?: boolean
@ -81,7 +96,7 @@ function nextRowId(): string {
} }
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow { function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' } return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '', assignments: [] }
} }
// Common expertise tags for suggestions // Common expertise tags for suggestions
@ -115,8 +130,12 @@ export default function MemberInvitePage() {
const [result, setResult] = useState<{ const [result, setResult] = useState<{
created: number created: number
skipped: number skipped: number
assignmentsCreated?: number
} | null>(null) } | null>(null)
// Pre-assignment state
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils() const utils = trpc.useUtils()
const bulkCreate = trpc.user.bulkCreate.useMutation({ const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => { onSuccess: () => {
@ -125,6 +144,33 @@ export default function MemberInvitePage() {
}, },
}) })
// Fetch programs with rounds for pre-assignment
const { data: programsData } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Flatten all rounds from all programs
const rounds = useMemo(() => {
if (!programsData) return []
type ProgramWithRounds = typeof programsData[number] & {
rounds?: Array<{ id: string; name: string }>
}
return (programsData as ProgramWithRounds[]).flatMap((program) =>
(program.rounds || []).map((round) => ({
id: round.id,
name: round.name,
programName: `${program.name} ${program.year}`,
}))
)
}, [programsData])
// Fetch projects for selected round
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
{ roundId: selectedRoundId, perPage: 200 },
{ enabled: !!selectedRoundId }
)
const projects = projectsData?.projects || []
// --- Manual entry helpers --- // --- Manual entry helpers ---
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => { const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
setRows((prev) => setRows((prev) =>
@ -176,6 +222,28 @@ export default function MemberInvitePage() {
).slice(0, 5) ).slice(0, 5)
} }
// Per-row project assignment management
const toggleProjectAssignment = (rowId: string, projectId: string) => {
if (!selectedRoundId) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== rowId) return r
const existing = r.assignments.find((a) => a.projectId === projectId)
if (existing) {
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
} else {
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
}
})
)
}
const clearRowAssignments = (rowId: string) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, assignments: [] } : r))
)
}
// --- CSV helpers --- // --- CSV helpers ---
const handleCSVUpload = useCallback( const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@ -252,6 +320,7 @@ export default function MemberInvitePage() {
name: r.name.trim() || undefined, name: r.name.trim() || undefined,
role: r.role, role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined, expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate, isValid: isValidFormat && !isDuplicate,
isDuplicate, isDuplicate,
error: !isValidFormat error: !isValidFormat
@ -298,6 +367,7 @@ export default function MemberInvitePage() {
name: u.name, name: u.name,
role: u.role, role: u.role,
expertiseTags: u.expertiseTags, expertiseTags: u.expertiseTags,
assignments: u.assignments,
})), })),
}) })
setSendProgress(100) setSendProgress(100)
@ -362,6 +432,35 @@ export default function MemberInvitePage() {
{inputMethod === 'manual' ? ( {inputMethod === 'manual' ? (
<div className="space-y-4"> <div className="space-y-4">
{/* Round selector for pre-assignments */}
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
<div className="flex items-center gap-3">
<FolderKanban className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
<p className="text-xs text-muted-foreground">
Select a round to assign projects to jury members before they onboard
</p>
</div>
<Select
value={selectedRoundId || 'none'}
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No pre-assignment</SelectItem>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Member cards */} {/* Member cards */}
{rows.map((row, index) => ( {rows.map((row, index) => (
<div <div
@ -500,6 +599,81 @@ export default function MemberInvitePage() {
</div> </div>
)} )}
</div> </div>
{/* Per-member project pre-assignment (only for jury members) */}
{row.role === 'JURY_MEMBER' && selectedRoundId && (
<Collapsible className="space-y-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-between text-muted-foreground hover:text-foreground"
>
<span className="flex items-center gap-2">
<FolderKanban className="h-4 w-4" />
Pre-assign Projects
{row.assignments.length > 0 && (
<Badge variant="secondary" className="ml-1">
{row.assignments.length}
</Badge>
)}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2">
{projectsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading projects...</span>
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No projects in this round
</p>
) : (
<div className="max-h-48 overflow-y-auto space-y-1 border rounded-lg p-2">
{projects.map((project) => {
const isAssigned = row.assignments.some(
(a) => a.projectId === project.id
)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted',
isAssigned && 'bg-primary/5'
)}
>
<Checkbox
checked={isAssigned}
onCheckedChange={() =>
toggleProjectAssignment(row.id, project.id)
}
/>
<span className="text-sm truncate flex-1">
{project.title}
</span>
</label>
)
})}
</div>
)}
{row.assignments.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => clearRowAssignments(row.id)}
className="text-xs text-muted-foreground"
>
Clear all assignments
</Button>
)}
</CollapsibleContent>
</Collapsible>
)}
</div> </div>
))} ))}
@ -722,6 +896,9 @@ export default function MemberInvitePage() {
{result?.skipped {result?.skipped
? ` ${result.skipped} skipped (already exist).` ? ` ${result.skipped} skipped (already exist).`
: ''} : ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
: ''}
</p> </p>
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
<Button variant="outline" asChild> <Button variant="outline" asChild>

View File

@ -1,686 +0,0 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
ArrowLeft,
Loader2,
Save,
Plus,
GripVertical,
Trash2,
Settings,
Eye,
Mail,
Link2,
} from 'lucide-react'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Sortable step item component
function SortableStep({
step,
isSelected,
onSelect,
onDelete,
}: {
step: { id: string; name: string; title: string; fields: unknown[] }
isSelected: boolean
onSelect: () => void
onDelete: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors',
isSelected ? 'border-primary bg-primary/5' : 'border-transparent hover:bg-muted',
isDragging && 'opacity-50'
)}
onClick={onSelect}
>
<button
{...attributes}
{...listeners}
className="cursor-grab text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{step.title}</p>
<p className="text-xs text-muted-foreground">
{(step.fields as unknown[]).length} fields
</p>
</div>
<button
className="p-1 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
function OnboardingFormEditor({ formId }: { formId: string }) {
const router = useRouter()
const utils = trpc.useUtils()
// Fetch form data with steps
const { data: form, isLoading } = trpc.applicationForm.getForBuilder.useQuery(
{ id: formId },
{ refetchOnWindowFocus: false }
)
// Local state for editing
const [selectedStepId, setSelectedStepId] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Mutations
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Form updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const createStep = trpc.applicationForm.createStep.useMutation({
onSuccess: (data) => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(data.id)
toast.success('Step created')
},
})
const updateStep = trpc.applicationForm.updateStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Step updated')
},
})
const deleteStep = trpc.applicationForm.deleteStep.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
setSelectedStepId(null)
toast.success('Step deleted')
},
})
const reorderSteps = trpc.applicationForm.reorderSteps.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
},
})
const updateEmailSettings = trpc.applicationForm.updateEmailSettings.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Email settings updated')
},
})
const linkToRound = trpc.applicationForm.linkToRound.useMutation({
onSuccess: () => {
utils.applicationForm.getForBuilder.invalidate({ id: formId })
toast.success('Round linked')
},
})
// Fetch available rounds
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery({
programId: form?.programId || undefined,
})
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleStepDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id || !form) return
const oldIndex = form.steps.findIndex((s) => s.id === active.id)
const newIndex = form.steps.findIndex((s) => s.id === over.id)
const newOrder = arrayMove(form.steps, oldIndex, newIndex)
reorderSteps.mutate({
formId,
stepIds: newOrder.map((s) => s.id),
})
}
const handleAddStep = () => {
const stepNumber = (form?.steps.length || 0) + 1
createStep.mutate({
formId,
step: {
name: `step_${stepNumber}`,
title: `Step ${stepNumber}`,
},
})
}
const selectedStep = form?.steps.find((s) => s.id === selectedStepId)
if (isLoading) {
return <FormEditorSkeleton />
}
if (!form) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Form not found</p>
<Link href="/admin/onboarding">
<Button variant="outline" className="mt-4">
Back to Onboarding
</Button>
</Link>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/onboarding">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{form.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={form.status === 'PUBLISHED' ? 'default' : 'secondary'}>
{form.status}
</Badge>
{form.round && (
<Badge variant="outline">
<Link2 className="mr-1 h-3 w-3" />
{form.round.name}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a href={`/apply/${form.publicSlug}`} target="_blank" rel="noopener noreferrer">
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList>
<TabsTrigger value="steps">Steps & Fields</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="emails">Emails</TabsTrigger>
</TabsList>
{/* Steps Tab */}
<TabsContent value="steps" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Steps List */}
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-base">Steps</CardTitle>
<CardDescription>
Drag to reorder wizard steps
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={form.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{form.steps.map((step) => (
<SortableStep
key={step.id}
step={step}
isSelected={selectedStepId === step.id}
onSelect={() => setSelectedStepId(step.id)}
onDelete={() => {
if (confirm('Delete this step? Fields will be unassigned.')) {
deleteStep.mutate({ id: step.id })
}
}}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={handleAddStep}
disabled={createStep.isPending}
>
{createStep.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add Step
</Button>
</CardContent>
</Card>
{/* Step Editor */}
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{selectedStep ? `Edit: ${selectedStep.title}` : 'Select a Step'}
</CardTitle>
</CardHeader>
<CardContent>
{selectedStep ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Step Title</Label>
<Input
value={selectedStep.title}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { title: e.target.value },
})
}}
/>
</div>
<div className="space-y-2">
<Label>Internal Name</Label>
<Input
value={selectedStep.name}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { name: e.target.value },
})
}}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Textarea
value={selectedStep.description || ''}
onChange={(e) => {
updateStep.mutate({
id: selectedStep.id,
step: { description: e.target.value },
})
}}
rows={3}
/>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-3">Fields in this step</h4>
{selectedStep.fields.length === 0 ? (
<p className="text-sm text-muted-foreground">
No fields yet. Use the existing form editor to add fields.
</p>
) : (
<div className="space-y-2">
{selectedStep.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-muted/50"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium text-sm">{field.label}</p>
<p className="text-xs text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</p>
</div>
</div>
))}
</div>
)}
<Link href={`/admin/forms/${formId}`}>
<Button variant="outline" size="sm" className="mt-4">
Edit Fields in Form Editor
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted-foreground text-center py-8">
Select a step from the list to edit it
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">General Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Form Name</Label>
<Input
value={form.name}
onChange={(e) => {
updateForm.mutate({ id: formId, name: e.target.value })
}}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, description: e.target.value || null })
}}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
value={form.publicSlug || ''}
onChange={(e) => {
updateForm.mutate({ id: formId, publicSlug: e.target.value || null })
}}
placeholder="your-form-slug"
/>
</div>
</div>
<div className="flex items-center justify-between pt-4">
<div>
<Label>Public Access</Label>
<p className="text-sm text-muted-foreground">
Allow public submissions to this form
</p>
</div>
<Switch
checked={form.isPublic}
onCheckedChange={(checked) => {
updateForm.mutate({ id: formId, isPublic: checked })
}}
/>
</div>
<div className="space-y-2 pt-4">
<Label>Status</Label>
<Select
value={form.status}
onValueChange={(value) => {
updateForm.mutate({ id: formId, status: value as 'DRAFT' | 'PUBLISHED' | 'CLOSED' })
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Round Linking</CardTitle>
<CardDescription>
Link this form to a round to create projects on submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Linked Round</Label>
<Select
value={form.roundId || 'none'}
onValueChange={(value) => {
linkToRound.mutate({
formId,
roundId: value === 'none' ? null : value,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No round linked</SelectItem>
{form.round && (
<SelectItem value={form.round.id}>
{form.round.name} (current)
</SelectItem>
)}
{availableRounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program?.name} {round.program?.year} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Emails Tab */}
<TabsContent value="emails" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Email Notifications</CardTitle>
<CardDescription>
Configure emails sent when applications are submitted
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label>Confirmation Email</Label>
<p className="text-sm text-muted-foreground">
Send a confirmation email to the applicant
</p>
</div>
<Switch
checked={form.sendConfirmationEmail}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendConfirmationEmail: checked,
})
}}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Team Invite Emails</Label>
<p className="text-sm text-muted-foreground">
Send invite emails to team members
</p>
</div>
<Switch
checked={form.sendTeamInviteEmails}
onCheckedChange={(checked) => {
updateEmailSettings.mutate({
formId,
sendTeamInviteEmails: checked,
})
}}
/>
</div>
{form.sendConfirmationEmail && (
<>
<div className="space-y-2 pt-4 border-t">
<Label>Custom Email Subject (optional)</Label>
<Input
value={form.confirmationEmailSubject || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailSubject: e.target.value || null,
})
}}
placeholder="Application Received - {projectName}"
/>
</div>
<div className="space-y-2">
<Label>Custom Email Message (optional)</Label>
<Textarea
value={form.confirmationEmailBody || ''}
onChange={(e) => {
updateEmailSettings.mutate({
formId,
confirmationEmailBody: e.target.value || null,
})
}}
placeholder="Add a custom message to include in the confirmation email..."
rows={4}
/>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function FormEditorSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-24" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-32" />
</div>
</div>
<Skeleton className="h-10 w-64" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-96" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
export default function OnboardingFormPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<FormEditorSkeleton />}>
<OnboardingFormEditor formId={id} />
</Suspense>
)
}

View File

@ -1,171 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewOnboardingFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
// Fetch programs for selection
const { data: programs } = trpc.program.list.useQuery({})
// Fetch available rounds for the selected program
const { data: availableRounds } = trpc.applicationForm.getAvailableRounds.useQuery(
{ programId: selectedProgramId || undefined },
{ enabled: true }
)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Onboarding form created successfully')
router.push(`/admin/onboarding/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: selectedProgramId || null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/onboarding">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Onboarding Form</h1>
<p className="text-muted-foreground">
Set up a new application wizard for project submissions
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Configure the basic settings for your onboarding form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., MOPC 2026 Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Edition / Program</Label>
<Select
value={selectedProgramId}
onValueChange={setSelectedProgramId}
>
<SelectTrigger>
<SelectValue placeholder="Select a program (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">No program</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Link to a specific edition to enable project creation
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this application form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., mopc-2026"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/onboarding">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -1,153 +0,0 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Link2,
} from 'lucide-react'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function OnboardingFormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No onboarding forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application wizard to accept project submissions
</p>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Onboarding Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
{form.program && (
<Badge variant="outline">{form.program.name} {form.program.year}</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/onboarding/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function OnboardingPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Onboarding</h1>
<p className="text-muted-foreground">
Configure application wizards for project submissions
</p>
</div>
<Link href="/admin/onboarding/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<OnboardingFormsList />
</Suspense>
</div>
)
}

View File

@ -112,11 +112,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId }, where: { programId: editionId },
}), }),
prisma.project.count({ prisma.project.count({
where: { programId: editionId }, where: { round: { programId: editionId } },
}), }),
prisma.project.count({ prisma.project.count({
where: { where: {
programId: editionId, round: { programId: editionId },
createdAt: { gte: sevenDaysAgo }, createdAt: { gte: sevenDaysAgo },
}, },
}), }),
@ -149,7 +149,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
include: { include: {
_count: { _count: {
select: { select: {
roundProjects: true, projects: true,
assignments: true, assignments: true,
}, },
}, },
@ -161,7 +161,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
}, },
}), }),
prisma.project.findMany({ prisma.project.findMany({
where: { programId: editionId }, where: { round: { programId: editionId } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, take: 8,
select: { select: {
@ -174,20 +174,18 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
logoKey: true, logoKey: true,
createdAt: true, createdAt: true,
submittedAt: true, submittedAt: true,
roundProjects: { status: true,
select: { status: true, round: { select: { name: true } } }, round: { select: { name: true } },
take: 1,
},
}, },
}), }),
prisma.project.groupBy({ prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { programId: editionId }, where: { round: { programId: editionId } },
_count: true, _count: true,
}), }),
prisma.project.groupBy({ prisma.project.groupBy({
by: ['oceanIssue'], by: ['oceanIssue'],
where: { programId: editionId }, where: { round: { programId: editionId } },
_count: true, _count: true,
}), }),
]) ])
@ -394,7 +392,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{round._count.roundProjects} projects &middot; {round._count.assignments} assignments {round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && ( {round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</> <> &middot; {round.evalPercent}% evaluated</>
)} )}
@ -461,10 +459,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
{truncate(project.title, 45)} {truncate(project.title, 45)}
</p> </p>
<Badge <Badge
variant={statusColors[project.roundProjects[0]?.status ?? 'SUBMITTED'] || 'secondary'} variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0" className="shrink-0 text-[10px] px-1.5 py-0"
> >
{(project.roundProjects[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">

View File

@ -130,7 +130,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
{round.status} {round.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{round._count.roundProjects}</TableCell> <TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell> <TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell> <TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow> </TableRow>

View File

@ -121,7 +121,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
// Fetch existing tags for suggestions // Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({ const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundProjects?.[0]?.round?.id, roundId: project?.roundId ?? undefined,
}) })
// Mutations // Mutations
@ -167,7 +167,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: project.title, title: project.title,
teamName: project.teamName || '', teamName: project.teamName || '',
description: project.description || '', description: project.description || '',
status: (project.roundProjects?.[0]?.status ?? 'SUBMITTED') as UpdateProjectForm['status'], status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
tags: project.tags || [], tags: project.tags || [],
}) })
} }
@ -202,7 +202,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
teamName: data.teamName || null, teamName: data.teamName || null,
description: data.description || null, description: data.description || null,
status: data.status, status: data.status,
roundId: project?.roundProjects?.[0]?.round?.id, roundId: project?.roundId ?? undefined,
tags: data.tags, tags: data.tags,
}) })
} }

View File

@ -160,7 +160,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p> <p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && ( {project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag) => ( {project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge> <Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))} ))}
</div> </div>

View File

@ -140,18 +140,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/> />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
{project.roundProjects?.length > 0 ? ( {project.roundId ? (
project.roundProjects.map((rp, i) => (
<span key={rp.round.id} className="flex items-center gap-1">
{i > 0 && <span className="text-muted-foreground/50">/</span>}
<Link <Link
href={`/admin/rounds/${rp.round.id}`} href={`/admin/rounds/${project.roundId}`}
className="hover:underline" className="hover:underline"
> >
{rp.round.name} {project.round?.name ?? 'Round'}
</Link> </Link>
</span>
))
) : ( ) : (
<span>No round</span> <span>No round</span>
)} )}
@ -160,8 +155,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
</h1> </h1>
<Badge variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'}> <Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</div> </div>
{project.teamName && ( {project.teamName && (
@ -513,7 +508,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription> </CardDescription>
</div> </div>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundProjects?.[0]?.round?.id}/assignments`}> <Link href={`/admin/rounds/${project.roundId}/assignments`}>
Manage Manage
</Link> </Link>
</Button> </Button>

View File

@ -121,7 +121,6 @@ function NewProjectPageContent() {
}) })
createProject.mutate({ createProject.mutate({
programId: selectedRound!.programId,
roundId: selectedRoundId, roundId: selectedRoundId,
title: title.trim(), title: title.trim(),
teamName: teamName.trim() || undefined, teamName: teamName.trim() || undefined,

View File

@ -359,7 +359,7 @@ export default function ProjectsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.projects.map((project) => { {data.projects.map((project) => {
const isEliminated = project.roundProjects?.[0]?.status === 'REJECTED' const isEliminated = project.status === 'REJECTED'
return ( return (
<TableRow <TableRow
key={project.id} key={project.id}
@ -388,15 +388,15 @@ export default function ProjectsPage() {
<TableCell> <TableCell>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p>{project.roundProjects?.[0]?.round?.name ?? '-'}</p> <p>{project.round?.name ?? '-'}</p>
{project.roundProjects?.[0]?.status === 'REJECTED' && ( {project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs"> <Badge variant="destructive" className="text-xs">
Eliminated Eliminated
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{project.program?.name} {project.round?.program?.name}
</p> </p>
</div> </div>
</TableCell> </TableCell>
@ -409,9 +409,9 @@ export default function ProjectsPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary'} variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
> >
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="relative z-10 text-right"> <TableCell className="relative z-10 text-right">
@ -478,11 +478,11 @@ export default function ProjectsPage() {
</CardTitle> </CardTitle>
<Badge <Badge
variant={ variant={
statusColors[project.roundProjects?.[0]?.status ?? 'SUBMITTED'] || 'secondary' statusColors[project.status ?? 'SUBMITTED'] || 'secondary'
} }
className="shrink-0" className="shrink-0"
> >
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</div> </div>
<CardDescription>{project.teamName}</CardDescription> <CardDescription>{project.teamName}</CardDescription>
@ -493,8 +493,8 @@ export default function ProjectsPage() {
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span> <span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{project.roundProjects?.[0]?.round?.name ?? '-'}</span> <span>{project.round?.name ?? '-'}</span>
{project.roundProjects?.[0]?.status === 'REJECTED' && ( {project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs"> <Badge variant="destructive" className="text-xs">
Eliminated Eliminated
</Badge> </Badge>

View File

@ -72,7 +72,7 @@ export interface ProjectFilters {
} }
interface FilterOptions { interface FilterOptions {
rounds: Array<{ id: string; name: string; sortOrder: number; program: { name: string; year: number } }> rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
countries: string[] countries: string[]
categories: Array<{ value: string; count: number }> categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }> issues: Array<{ value: string; count: number }>

View File

@ -84,7 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
const [formInitialized, setFormInitialized] = 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>>({})
const [entryNotificationType, setEntryNotificationType] = useState<string>('') // entryNotificationType removed from schema
// Fetch round data - disable refetch on focus to prevent overwriting user's edits // 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(
@ -138,7 +138,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
// Set round type, settings, and notification type // Set round type, settings, and notification type
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>) || {})
setEntryNotificationType(round.entryNotificationType || '')
setFormInitialized(true) setFormInitialized(true)
} }
}, [round, form, formInitialized]) }, [round, form, formInitialized])
@ -166,7 +165,6 @@ function EditRoundContent({ roundId }: { roundId: string }) {
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null, votingStartAt: data.votingStartAt ?? null,
votingEndAt: data.votingEndAt ?? null, votingEndAt: data.votingEndAt ?? null,
entryNotificationType: entryNotificationType || null,
}) })
// Update evaluation form if criteria changed and no evaluations exist // Update evaluation form if criteria changed and no evaluations exist
@ -353,38 +351,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Team Notification */} {/* Team Notification - removed from schema, feature not implemented */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Bell className="h-5 w-5" />
Team Notification
</CardTitle>
<CardDescription>
Notification sent to project teams when they enter this round
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={entryNotificationType || 'none'}
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
>
<SelectTrigger>
<SelectValue placeholder="No automatic notification" />
</SelectTrigger>
<SelectContent>
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground mt-2">
When projects advance to this round, the selected notification will be sent to the project team automatically.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */} {/* Evaluation Criteria */}
<Card> <Card>

View File

@ -180,7 +180,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
if (storedOrder.length > 0) { if (storedOrder.length > 0) {
setProjectOrder(storedOrder) setProjectOrder(storedOrder)
} else { } else {
setProjectOrder(sessionData.round.roundProjects.map((rp) => rp.project.id)) setProjectOrder(sessionData.round.projects.map((p) => p.id))
} }
} }
}, [sessionData]) }, [sessionData])
@ -253,7 +253,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
) )
} }
const projects = sessionData.round.roundProjects.map((rp) => rp.project) const projects = sessionData.round.projects
const sortedProjects = projectOrder const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id)) .map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p) .filter((p): p is Project => !!p)

View File

@ -367,7 +367,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{round._count.roundProjects}</div> <div className="text-2xl font-bold">{round._count.projects}</div>
<Button variant="link" size="sm" className="px-0" asChild> <Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link> <Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button> </Button>

View File

@ -75,7 +75,7 @@ type RoundData = {
votingStartAt: string | null votingStartAt: string | null
votingEndAt: string | null votingEndAt: string | null
_count?: { _count?: {
roundProjects: number projects: number
assignments: number assignments: number
} }
} }
@ -238,7 +238,7 @@ function ProgramRounds({ program }: { program: any }) {
{round.name} {round.name}
</span> </span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0"> <Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.roundProjects || 0} {round._count?.projects || 0}
</Badge> </Badge>
</div> </div>
{index < rounds.length - 1 && ( {index < rounds.length - 1 && (
@ -425,7 +425,7 @@ function SortableRoundRow({
{/* Projects */} {/* Projects */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.roundProjects || 0}</span> <span className="font-medium">{round._count?.projects || 0}</span>
</div> </div>
{/* Assignments */} {/* Assignments */}
@ -509,7 +509,7 @@ function SortableRoundRow({
<AlertDialogTitle>Delete Round</AlertDialogTitle> <AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.roundProjects || 0} project assignments,{' '} remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations {round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone. in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>

View File

@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input' import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import { import {
Card, Card,
CardContent, CardContent,
@ -23,6 +24,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ExpertiseSelect } from '@/components/shared/expertise-select' import { ExpertiseSelect } from '@/components/shared/expertise-select'
import { AvatarUpload } from '@/components/shared/avatar-upload'
import { import {
User, User,
Phone, Phone,
@ -32,9 +34,11 @@ import {
Loader2, Loader2,
ArrowRight, ArrowRight,
ArrowLeft, ArrowLeft,
Camera,
Globe,
} from 'lucide-react' } from 'lucide-react'
type Step = 'name' | 'phone' | 'tags' | 'preferences' | 'complete' type Step = 'name' | 'photo' | 'country' | 'phone' | 'tags' | 'preferences' | 'complete'
export default function OnboardingPage() { export default function OnboardingPage() {
const router = useRouter() const router = useRouter()
@ -43,6 +47,7 @@ export default function OnboardingPage() {
// Form state // Form state
const [name, setName] = useState('') const [name, setName] = useState('')
const [country, setCountry] = useState('')
const [phoneNumber, setPhoneNumber] = useState('') const [phoneNumber, setPhoneNumber] = useState('')
const [expertiseTags, setExpertiseTags] = useState<string[]>([]) const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [lockedTags, setLockedTags] = useState<string[]>([]) const [lockedTags, setLockedTags] = useState<string[]>([])
@ -51,7 +56,8 @@ export default function OnboardingPage() {
>('EMAIL') >('EMAIL')
// Fetch current user data to get admin-preset tags // Fetch current user data to get admin-preset tags
const { data: userData, isLoading: userLoading } = trpc.user.me.useQuery() const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery()
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
// Initialize form with user data // Initialize form with user data
useEffect(() => { useEffect(() => {
@ -60,6 +66,10 @@ export default function OnboardingPage() {
if (userData.name) { if (userData.name) {
setName(userData.name) setName(userData.name)
} }
// Pre-fill country if available
if (userData.country) {
setCountry(userData.country)
}
// Pre-fill phone if available // Pre-fill phone if available
if (userData.phoneNumber) { if (userData.phoneNumber) {
setPhoneNumber(userData.phoneNumber) setPhoneNumber(userData.phoneNumber)
@ -86,10 +96,10 @@ export default function OnboardingPage() {
// Dynamic steps based on WhatsApp availability // Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => { const steps: Step[] = useMemo(() => {
if (whatsappEnabled) { if (whatsappEnabled) {
return ['name', 'phone', 'tags', 'preferences', 'complete'] return ['name', 'photo', 'country', 'phone', 'tags', 'preferences', 'complete']
} }
// Skip phone step if WhatsApp is disabled // Skip phone step if WhatsApp is disabled
return ['name', 'tags', 'preferences', 'complete'] return ['name', 'photo', 'country', 'tags', 'preferences', 'complete']
}, [whatsappEnabled]) }, [whatsappEnabled])
const currentIndex = steps.indexOf(step) const currentIndex = steps.indexOf(step)
@ -117,6 +127,7 @@ export default function OnboardingPage() {
try { try {
await completeOnboarding.mutateAsync({ await completeOnboarding.mutateAsync({
name, name,
country: country || undefined,
phoneNumber: phoneNumber || undefined, phoneNumber: phoneNumber || undefined,
expertiseTags, expertiseTags,
notificationPreference, notificationPreference,
@ -127,7 +138,9 @@ export default function OnboardingPage() {
// Redirect after a short delay based on user role // Redirect after a short delay based on user role
setTimeout(() => { setTimeout(() => {
const role = userData?.role const role = userData?.role
if (role === 'MENTOR') { if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (role === 'MENTOR') {
router.push('/mentor') router.push('/mentor')
} else if (role === 'OBSERVER') { } else if (role === 'OBSERVER') {
router.push('/observer') router.push('/observer')
@ -211,7 +224,85 @@ export default function OnboardingPage() {
</> </>
)} )}
{/* Step 2: Phone (only if WhatsApp enabled) */} {/* Step 2: Profile Photo */}
{step === 'photo' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-primary" />
Profile Photo
</CardTitle>
<CardDescription>
Add a profile photo so others can recognize you. This step is optional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<AvatarUpload
user={{
name: userData?.name,
email: userData?.email,
profileImageKey: userData?.profileImageKey,
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
/>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo. You can crop and adjust before saving.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
{avatarUrl ? 'Continue' : 'Skip for now'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 3: Home Country */}
{step === 'country' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
Home Country
</CardTitle>
<CardDescription>
Select your home country. This helps us match you with relevant projects.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<CountrySelect
value={country}
onChange={setCountry}
placeholder="Select your country"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step 4: Phone (only if WhatsApp enabled) */}
{step === 'phone' && whatsappEnabled && ( {step === 'phone' && whatsappEnabled && (
<> <>
<CardHeader> <CardHeader>
@ -252,7 +343,7 @@ export default function OnboardingPage() {
</> </>
)} )}
{/* Step 3: Tags */} {/* Step 5: Tags */}
{step === 'tags' && ( {step === 'tags' && (
<> <>
<CardHeader> <CardHeader>
@ -286,7 +377,7 @@ export default function OnboardingPage() {
</> </>
)} )}
{/* Step 4: Preferences */} {/* Step 6: Preferences */}
{step === 'preferences' && ( {step === 'preferences' && (
<> <>
<CardHeader> <CardHeader>
@ -338,6 +429,11 @@ export default function OnboardingPage() {
<p> <p>
<span className="text-muted-foreground">Name:</span> {name} <span className="text-muted-foreground">Name:</span> {name}
</p> </p>
{country && (
<p>
<span className="text-muted-foreground">Country:</span> {country}
</p>
)}
{whatsappEnabled && phoneNumber && ( {whatsappEnabled && phoneNumber && (
<p> <p>
<span className="text-muted-foreground">Phone:</span>{' '} <span className="text-muted-foreground">Phone:</span>{' '}
@ -375,7 +471,7 @@ export default function OnboardingPage() {
</> </>
)} )}
{/* Step 5: Complete */} {/* Step 7: Complete */}
{step === 'complete' && ( {step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-green-100 p-4 mb-4"> <div className="rounded-full bg-green-100 p-4 mb-4">

View File

@ -149,22 +149,22 @@ export default function MentorDashboard() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span> <span>
{project.program.year} Edition {project.round?.program?.year} Edition
</span> </span>
{project.roundProjects?.[0]?.round && ( {project.round && (
<> <>
<span></span> <span></span>
<span>{project.roundProjects[0].round.name}</span> <span>{project.round.name}</span>
</> </>
)} )}
</div> </div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{project.title} {project.title}
{project.roundProjects?.[0]?.status && ( {project.status && (
<Badge <Badge
variant={statusColors[project.roundProjects[0].status] || 'secondary'} variant={statusColors[project.status] || 'secondary'}
> >
{project.roundProjects[0].status.replace('_', ' ')} {project.status.replace('_', ' ')}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>

View File

@ -109,12 +109,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span> <span>
{project.program.year} Edition {project.round?.program?.year} Edition
</span> </span>
{project.roundProjects?.[0]?.round && ( {project.round && (
<> <>
<span></span> <span></span>
<span>{project.roundProjects[0].round.name}</span> <span>{project.round.name}</span>
</> </>
)} )}
</div> </div>
@ -122,9 +122,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{project.title} {project.title}
</h1> </h1>
{project.roundProjects?.[0]?.status && ( {project.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}> <Badge variant={statusColors[project.status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')} {project.status.replace('_', ' ')}
</Badge> </Badge>
)} )}
</div> </div>

View File

@ -94,20 +94,20 @@ export default function MentorProjectsPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span> <span>
{project.program.year} Edition {project.round?.program?.year} Edition
</span> </span>
{project.roundProjects?.[0]?.round && ( {project.round && (
<> <>
<span></span> <span></span>
<span>{project.roundProjects[0].round.name}</span> <span>{project.round.name}</span>
</> </>
)} )}
</div> </div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
{project.title} {project.title}
{project.roundProjects?.[0]?.status && ( {project.status && (
<Badge variant={statusColors[project.roundProjects[0].status] || 'secondary'}> <Badge variant={statusColors[project.status] || 'secondary'}>
{project.roundProjects[0].status.replace('_', ' ')} {project.status.replace('_', ' ')}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>

View File

@ -48,7 +48,7 @@ async function ObserverDashboardContent() {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
_count: { _count: {
select: { select: {
roundProjects: true, projects: true,
assignments: true, assignments: true,
}, },
}, },
@ -176,7 +176,7 @@ async function ObserverDashboardContent() {
</p> </p>
</div> </div>
<div className="text-right text-sm"> <div className="text-right text-sm">
<p>{round._count.roundProjects} projects</p> <p>{round._count.projects} projects</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{round._count.assignments} assignments {round._count.assignments} assignments
</p> </p>

View File

@ -34,7 +34,7 @@ async function ReportsContent() {
}, },
_count: { _count: {
select: { select: {
roundProjects: true, projects: true,
assignments: true, assignments: true,
}, },
}, },
@ -70,7 +70,7 @@ async function ReportsContent() {
}) })
// Calculate totals // Calculate totals
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.roundProjects, 0) const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce( const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments, (acc, r) => acc + r.totalAssignments,
0 0
@ -176,7 +176,7 @@ async function ReportsContent() {
</div> </div>
</TableCell> </TableCell>
<TableCell>{round.program.name}</TableCell> <TableCell>{round.program.name}</TableCell>
<TableCell>{round._count.roundProjects}</TableCell> <TableCell>{round._count.projects}</TableCell>
<TableCell> <TableCell>
<div className="min-w-[120px] space-y-1"> <div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
@ -237,7 +237,7 @@ async function ReportsContent() {
</p> </p>
)} )}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span>{round._count.roundProjects} projects</span> <span>{round._count.projects} projects</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations {round.completedEvaluations}/{round.totalAssignments} evaluations
</span> </span>

View File

@ -1,423 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
roundId: config.round.id,
data,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error.message}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@ -1,430 +1,423 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} 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 { toast } from 'sonner' import { toast } from 'sonner'
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react' import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
type FormField = { // Form validation schema
id: string const teamMemberSchema = z.object({
fieldType: string name: z.string().min(1, 'Name is required'),
name: string email: z.string().email('Invalid email address'),
label: string role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
description?: string | null title: z.string().optional(),
placeholder?: string | null })
required: boolean
minLength?: number | null
maxLength?: number | null
minValue?: number | null
maxValue?: number | null
optionsJson: Array<{ value: string; label: string }> | null
conditionJson: { fieldId: string; operator: string; value?: string } | null
width: string
}
export default function PublicFormPage() { const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const slug = params.slug as string const slug = params.slug as string
const [submitted, setSubmitted] = useState(false)
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery( const [currentStep, setCurrentStep] = useState(0)
{ slug }, const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false } { retry: false }
) )
const submitMutation = trpc.applicationForm.submit.useMutation({ const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
setSubmitted(true) setSubmitted(true)
setConfirmationMessage(result.confirmationMessage || null) setSubmissionMessage(result.message)
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message) toast.error(error.message)
}, },
}) })
const { const form = useForm<ApplicationFormData>({
register, resolver: zodResolver(applicationSchema),
handleSubmit, defaultValues: {
watch, competitionCategory: undefined,
formState: { errors, isSubmitting }, contactName: '',
setValue, contactEmail: '',
} = useForm() contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const watchedValues = watch() const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const onSubmit = async (data: Record<string, unknown>) => { const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
if (!form) return const isStartup = competitionCategory === 'STARTUP'
// Extract email and name if present const validateCurrentStep = async () => {
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL') const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
const email = emailField ? (data[emailField.name] as string) : undefined if (currentFields.length === 0) return true
return await trigger(currentFields)
}
// Find a name field (common patterns) const nextStep = async () => {
const nameField = form.fields.find( const isValid = await validateCurrentStep()
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT' if (isValid && currentStep < STEPS.length - 1) {
) setDirection(1)
const name = nameField ? (data[nameField.name] as string) : undefined setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({ await submitMutation.mutateAsync({
formId: form.id, roundId: config.round.id,
data, data,
email,
name,
}) })
} }
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<Card> <div className="w-full max-w-2xl space-y-6">
<CardHeader> <div className="flex items-center justify-center gap-3">
<Skeleton className="h-8 w-64" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<Skeleton className="h-4 w-full" /> <span className="text-lg text-muted-foreground">Loading application...</span>
</CardHeader> </div>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div> </div>
))}
</CardContent>
</Card>
</div> </div>
) )
} }
// Error state
if (error) { if (error) {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<Card> <div className="w-full max-w-md text-center">
<CardContent className="flex flex-col items-center justify-center py-12"> <AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<AlertCircle className="h-12 w-12 text-destructive mb-4" /> <h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2> <p className="text-muted-foreground mb-6">{error.message}</p>
<p className="text-muted-foreground text-center"> <Button variant="outline" onClick={() => router.push('/')}>
{error.message} Return Home
</p> </Button>
</CardContent> </div>
</Card>
</div> </div>
) )
} }
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) { if (submitted) {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<Card> <motion.div
<CardContent className="flex flex-col items-center justify-center py-12"> initial={{ scale: 0.8, opacity: 0 }}
<CheckCircle className="h-12 w-12 text-green-500 mb-4" /> animate={{ scale: 1, opacity: 1 }}
<h2 className="text-xl font-semibold mb-2">Thank You!</h2> className="w-full max-w-md text-center"
<p className="text-muted-foreground text-center">
{confirmationMessage || 'Your submission has been received.'}
</p>
</CardContent>
</Card>
</div>
)
}
if (!form) return null
// Check if a field should be visible based on conditions
const isFieldVisible = (field: FormField): boolean => {
if (!field.conditionJson) return true
const condition = field.conditionJson
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
switch (condition.operator) {
case 'equals':
return dependentValue === condition.value
case 'not_equals':
return dependentValue !== condition.value
case 'not_empty':
return !!dependentValue && dependentValue !== ''
case 'contains':
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
default:
return true
}
}
const renderField = (field: FormField) => {
if (!isFieldVisible(field)) return null
const fieldError = errors[field.name]
const errorMessage = fieldError?.message as string | undefined
switch (field.fieldType) {
case 'SECTION':
return (
<div key={field.id} className="col-span-full pt-6 pb-2">
<h3 className="text-lg font-semibold">{field.label}</h3>
{field.description && (
<p className="text-sm text-muted-foreground">{field.description}</p>
)}
</div>
)
case 'INSTRUCTIONS':
return (
<div key={field.id} className="col-span-full">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm">{field.description || field.label}</p>
</div>
</div>
)
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<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 mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
placeholder={field.placeholder || undefined}
{...register(field.name, {
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,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'NUMBER':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<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 mb-1">{field.description}</p>
)}
<Input
id={field.name}
type="number"
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
valueAsNumber: true,
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'TEXTAREA':
return (
<div key={field.id} className="col-span-full">
<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 mb-1">{field.description}</p>
)}
<Textarea
id={field.name}
placeholder={field.placeholder || undefined}
rows={4}
{...register(field.name, {
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,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'DATE':
case 'DATETIME':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<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 mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'SELECT':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<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 mb-1">{field.description}</p>
)}
<Select
onValueChange={(value) => setValue(field.name, value)}
> >
<SelectTrigger> <motion.div
<SelectValue placeholder={field.placeholder || 'Select an option'} /> initial={{ scale: 0 }}
</SelectTrigger> animate={{ scale: 1 }}
<SelectContent> transition={{ delay: 0.2, type: 'spring' }}
{(field.optionsJson || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'RADIO':
return (
<div key={field.id} className="col-span-full">
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<RadioGroup
onValueChange={(value) => setValue(field.name, value)}
className="mt-2"
> >
{(field.optionsJson || []).map((option) => ( <CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
<div key={option.value} className="flex items-center space-x-2"> </motion.div>
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} /> <h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal"> <p className="text-muted-foreground mb-8">{submissionMessage}</p>
{option.label} <Button onClick={() => router.push('/')}>
</Label> Return Home
</div>
))}
</RadioGroup>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'CHECKBOX':
return (
<div key={field.id} className="col-span-full">
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
onCheckedChange={(checked) => setValue(field.name, checked)}
/>
<Label htmlFor={field.name} className="font-normal">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{field.description && (
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
)}
<input
type="hidden"
{...register(field.name, {
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
default:
return null
}
}
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>{form.name}</CardTitle>
{form.description && (
<CardDescription>{form.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{form.fields.map((field) => renderField(field as FormField))}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || submitMutation.isPending}
>
{(isSubmitting || submitMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
</Button> </Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form> </form>
</CardContent> </main>
</Card>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div> </div>
) )
} }

View File

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

View File

@ -1,160 +0,0 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { prisma } from '@/lib/prisma'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { FileText, Calendar, ArrowRight, ExternalLink } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default async function ApplyLandingPage() {
// Get all published, public application forms
const forms = await prisma.applicationForm.findMany({
where: {
status: 'PUBLISHED',
isPublic: true,
OR: [
{ opensAt: null },
{ opensAt: { lte: new Date() } },
],
AND: [
{
OR: [
{ closesAt: null },
{ closesAt: { gte: new Date() } },
],
},
],
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
description: true,
publicSlug: true,
opensAt: true,
closesAt: true,
steps: {
select: { id: true },
},
},
})
// If exactly one form is available, redirect to it
if (forms.length === 1 && forms[0].publicSlug) {
const form = forms[0]
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
redirect(url as Route)
}
// If no forms are available, show a message
if (forms.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-2xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<FileText className="h-16 w-16 text-muted-foreground/30 mb-6" />
<h1 className="text-2xl font-semibold mb-3">Applications Not Open</h1>
<p className="text-muted-foreground text-center max-w-md">
There are currently no open applications. Please check back later
or visit our website for more information.
</p>
<Button asChild className="mt-8">
<a href="https://monaco-opc.com" target="_blank" rel="noopener noreferrer">
Visit Website
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
// Multiple forms available - show selection
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-4xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
<h1 className="text-3xl font-bold mt-8 mb-3">Apply Now</h1>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Select an application form below to get started.
</p>
</div>
<div className="grid gap-6">
{forms.map((form) => {
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
return (
<Card key={form.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<Link href={url as Route} className="block">
<div className="flex items-stretch">
<div className="flex-1 p-6">
<CardHeader className="p-0 pb-2">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
{form.name}
</CardTitle>
{form.description && (
<CardDescription className="line-clamp-2">
{form.description}
</CardDescription>
)}
</CardHeader>
{(form.opensAt || form.closesAt) && (
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{form.closesAt && (
<span>
Closes: {new Date(form.closesAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</span>
)}
</div>
)}
</div>
<div className="flex items-center px-6 bg-muted/30 border-l">
<Button variant="ghost" size="icon" className="rounded-full">
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</div>
</Link>
</Card>
)
})}
</div>
<div className="text-center mt-12">
<p className="text-sm text-muted-foreground">
Having trouble? Contact us at{' '}
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
support@monaco-opc.com
</a>
</p>
</div>
</div>
</div>
)
}

View File

@ -132,7 +132,7 @@ export function SubmissionDetailClient() {
</Badge> </Badge>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{project.roundProjects?.[0]?.round?.program?.year ? `${project.roundProjects[0].round.program.year} Edition` : ''}{project.roundProjects?.[0]?.round?.name ? ` - ${project.roundProjects[0].round.name}` : ''} {project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
</p> </p>
</div> </div>
</div> </div>

View File

@ -132,10 +132,9 @@ export function MySubmissionClient() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{submissions.map((project) => { {submissions.map((project) => {
const latestRoundProject = project.roundProjects?.[0] const projectStatus = project.status ?? 'SUBMITTED'
const projectStatus = latestRoundProject?.status ?? 'SUBMITTED' const roundName = project.round?.name
const roundName = latestRoundProject?.round?.name const programYear = project.round?.program?.year
const programYear = latestRoundProject?.round?.program?.year
return ( return (
<Card key={project.id}> <Card key={project.id}>

View File

@ -240,7 +240,7 @@ export function AdvanceProjectsDialog({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -187,7 +187,7 @@ export function RemoveProjectsDialog({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{(project.roundProjects?.[0]?.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -28,7 +28,6 @@ import {
ChevronRight, ChevronRight,
BookOpen, BookOpen,
Handshake, Handshake,
FileText,
CircleDot, CircleDot,
History, History,
Trophy, Trophy,
@ -91,11 +90,6 @@ const navigation = [
href: '/admin/partners' as const, href: '/admin/partners' as const,
icon: Handshake, icon: Handshake,
}, },
{
name: 'Onboarding',
href: '/admin/onboarding' as const,
icon: FileText,
},
] ]
// Admin-only navigation // Admin-only navigation

View File

@ -15,8 +15,6 @@ import { learningResourceRouter } from './learningResource'
import { partnerRouter } from './partner' 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 { 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'
@ -52,8 +50,6 @@ export const appRouter = router({
partner: partnerRouter, partner: partnerRouter,
notionImport: notionImportRouter, notionImport: notionImportRouter,
typeformImport: typeformImportRouter, typeformImport: typeformImportRouter,
applicationForm: applicationFormRouter,
onboarding: onboardingRouter,
// Phase 2B routers // Phase 2B routers
tag: tagRouter, tag: tagRouter,
applicant: applicantRouter, applicant: applicantRouter,

View File

@ -148,10 +148,8 @@ export const analyticsRouter = router({
getProjectRankings: adminProcedure getProjectRankings: adminProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() })) .input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const roundProjects = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
assignments: { assignments: {
include: { include: {
@ -161,14 +159,11 @@ export const analyticsRouter = router({
}, },
}, },
}, },
},
},
}) })
// Calculate average scores // Calculate average scores
const rankings = roundProjects const rankings = projects
.map((rp) => { .map((project) => {
const project = rp.project
const allScores: number[] = [] const allScores: number[] = []
project.assignments.forEach((assignment) => { project.assignments.forEach((assignment) => {
@ -200,7 +195,7 @@ export const analyticsRouter = router({
id: project.id, id: project.id,
title: project.title, title: project.title,
teamName: project.teamName, teamName: project.teamName,
status: rp.status, status: project.status,
averageScore, averageScore,
evaluationCount: allScores.length, evaluationCount: allScores.length,
} }
@ -217,15 +212,15 @@ export const analyticsRouter = router({
getStatusBreakdown: adminProcedure getStatusBreakdown: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const roundProjects = await ctx.prisma.roundProject.groupBy({ const projects = await ctx.prisma.project.groupBy({
by: ['status'], by: ['status'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
}) })
return roundProjects.map((rp) => ({ return projects.map((p) => ({
status: rp.status, status: p.status,
count: rp._count, count: p._count,
})) }))
}), }),
@ -242,7 +237,7 @@ export const analyticsRouter = router({
jurorCount, jurorCount,
statusCounts, statusCounts,
] = await Promise.all([ ] = await Promise.all([
ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }), ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({ ctx.prisma.evaluation.count({
where: { where: {
@ -254,7 +249,7 @@ export const analyticsRouter = router({
by: ['userId'], by: ['userId'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
}), }),
ctx.prisma.roundProject.groupBy({ ctx.prisma.project.groupBy({
by: ['status'], by: ['status'],
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
@ -353,7 +348,7 @@ export const analyticsRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where = input.roundId const where = input.roundId
? { roundProjects: { some: { roundId: input.roundId } } } ? { roundId: input.roundId }
: { programId: input.programId } : { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({ const distribution = await ctx.prisma.project.groupBy({

View File

@ -62,7 +62,7 @@ export const applicantRouter = router({
const project = await ctx.prisma.project.findFirst({ const project = await ctx.prisma.project.findFirst({
where: { where: {
roundProjects: { some: { roundId: input.roundId } }, roundId: input.roundId,
OR: [ OR: [
{ submittedByUserId: ctx.user.id }, { submittedByUserId: ctx.user.id },
{ {
@ -74,16 +74,11 @@ export const applicantRouter = router({
}, },
include: { include: {
files: true, files: true,
roundProjects: {
where: { roundId: input.roundId },
include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
},
teamMembers: { teamMembers: {
include: { include: {
user: { user: {
@ -179,10 +174,10 @@ export const applicantRouter = router({
}, },
}) })
// Update RoundProject status if submitting // Update Project status if submitting
if (submit) { if (submit) {
await ctx.prisma.roundProject.updateMany({ await ctx.prisma.project.update({
where: { projectId: projectId }, where: { id: projectId },
data: { status: 'SUBMITTED' }, data: { status: 'SUBMITTED' },
}) })
} }
@ -198,21 +193,13 @@ export const applicantRouter = router({
// Create new project // Create new project
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.project.create({
data: { data: {
programId: roundForCreate.programId, roundId,
...data, ...data,
metadataJson: metadataJson as unknown ?? undefined, metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id, submittedByUserId: ctx.user.id,
submittedByEmail: ctx.user.email, submittedByEmail: ctx.user.email,
submissionSource: 'MANUAL', submissionSource: 'MANUAL',
submittedAt: submit ? now : null, submittedAt: submit ? now : null,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED', status: 'SUBMITTED',
}, },
}) })
@ -411,17 +398,12 @@ export const applicantRouter = router({
}, },
], ],
}, },
include: {
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
},
files: true, files: true,
teamMembers: { teamMembers: {
include: { include: {
@ -440,9 +422,8 @@ export const applicantRouter = router({
}) })
} }
// Get the latest round project status // Get the project status
const latestRoundProject = project.roundProjects[0] const currentStatus = project.status ?? 'SUBMITTED'
const currentStatus = latestRoundProject?.status ?? 'SUBMITTED'
// Build timeline // Build timeline
const timeline = [ const timeline = [
@ -508,17 +489,12 @@ export const applicantRouter = router({
}, },
], ],
}, },
include: {
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
},
files: true, files: true,
teamMembers: { teamMembers: {
include: { include: {

View File

@ -191,7 +191,7 @@ export const applicationRouter = router({
// Check if email already submitted for this round // Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({ const existingProject = await ctx.prisma.project.findFirst({
where: { where: {
roundProjects: { some: { roundId } }, roundId,
submittedByEmail: data.contactEmail, submittedByEmail: data.contactEmail,
}, },
}) })
@ -223,7 +223,7 @@ export const applicationRouter = router({
// Create the project // Create the project
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.project.create({
data: { data: {
programId: round.programId, roundId,
title: data.projectName, title: data.projectName,
teamName: data.teamName, teamName: data.teamName,
description: data.description, description: data.description,
@ -246,15 +246,6 @@ export const applicationRouter = router({
}, },
}) })
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Create team lead membership // Create team lead membership
await ctx.prisma.teamMember.create({ await ctx.prisma.teamMember.create({
data: { data: {
@ -362,7 +353,7 @@ export const applicationRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const existing = await ctx.prisma.project.findFirst({ const existing = await ctx.prisma.project.findFirst({
where: { where: {
roundProjects: { some: { roundId: input.roundId } }, roundId: input.roundId,
submittedByEmail: input.email, submittedByEmail: input.email,
}, },
}) })

File diff suppressed because it is too large Load Diff

View File

@ -374,17 +374,13 @@ export const assignmentRouter = router({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
_count: true, _count: true,
}), }),
ctx.prisma.roundProject.findMany({ ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}), }),
]) ])
@ -394,7 +390,7 @@ export const assignmentRouter = router({
}) })
const projectsWithFullCoverage = projectCoverage.filter( const projectsWithFullCoverage = projectCoverage.filter(
(rp) => rp.project._count.assignments >= round.requiredReviews (p) => p._count.assignments >= round.requiredReviews
).length ).length
return { return {
@ -446,20 +442,15 @@ export const assignmentRouter = router({
}) })
// Get all projects that need more assignments // Get all projects that need more assignments
const roundProjectEntries = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
tags: true, tags: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments to avoid duplicates // Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({ const existingAssignments = await ctx.prisma.assignment.findMany({
@ -583,10 +574,8 @@ export const assignmentRouter = router({
}) })
// Get all projects in the round // Get all projects in the round
const roundProjectEntries = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
@ -595,10 +584,7 @@ export const assignmentRouter = router({
teamName: true, teamName: true,
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments // Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({ const existingAssignments = await ctx.prisma.assignment.findMany({

View File

@ -103,10 +103,8 @@ export const exportRouter = router({
projectScores: adminProcedure projectScores: adminProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const roundProjectEntries = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
assignments: { assignments: {
include: { include: {
@ -116,13 +114,10 @@ export const exportRouter = router({
}, },
}, },
}, },
}, orderBy: { title: 'asc' },
},
orderBy: { project: { title: 'asc' } },
}) })
const data = roundProjectEntries.map((rp) => { const data = projects.map((p) => {
const p = rp.project
const evaluations = p.assignments const evaluations = p.assignments
.map((a) => a.evaluation) .map((a) => a.evaluation)
.filter((e) => e !== null) .filter((e) => e !== null)
@ -138,7 +133,7 @@ export const exportRouter = router({
return { return {
title: p.title, title: p.title,
teamName: p.teamName, teamName: p.teamName,
status: rp.status, status: p.status,
tags: p.tags.join(', '), tags: p.tags.join(', '),
totalEvaluations: evaluations.length, totalEvaluations: evaluations.length,
averageScore: averageScore:

View File

@ -27,19 +27,14 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
}) })
// Get projects // Get projects
const roundProjectEntries = await prisma.roundProject.findMany({ const projects = await prisma.project.findMany({
where: { roundId }, where: { roundId },
include: {
project: {
include: { include: {
files: { files: {
select: { id: true, fileName: true, fileType: true }, select: { id: true, fileName: true, fileType: true },
}, },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
// Calculate batch info // Calculate batch info
const BATCH_SIZE = 20 const BATCH_SIZE = 20
@ -387,7 +382,7 @@ export const filteringRouter = router({
} }
// Count projects // Count projects
const projectCount = await ctx.prisma.roundProject.count({ const projectCount = await ctx.prisma.project.count({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
}) })
if (projectCount === 0) { if (projectCount === 0) {
@ -485,19 +480,14 @@ export const filteringRouter = router({
} }
// Get projects in this round // Get projects in this round
const roundProjectEntries = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: {
project: {
include: { include: {
files: { files: {
select: { id: true, fileName: true, fileType: true }, select: { id: true, fileName: true, fileType: true },
}, },
}, },
},
},
}) })
const projects = roundProjectEntries.map((rp) => rp.project)
if (projects.length === 0) { if (projects.length === 0) {
throw new TRPCError({ throw new TRPCError({
@ -755,32 +745,29 @@ export const filteringRouter = router({
// Filtered out projects get REJECTED status (data preserved) // Filtered out projects get REJECTED status (data preserved)
if (filteredOutIds.length > 0) { if (filteredOutIds.length > 0) {
operations.push( operations.push(
ctx.prisma.roundProject.updateMany({ ctx.prisma.project.updateMany({
where: { roundId: input.roundId, projectId: { in: filteredOutIds } }, where: { roundId: input.roundId, id: { in: filteredOutIds } },
data: { status: 'REJECTED' }, data: { status: 'REJECTED' },
}) })
) )
} }
// Passed projects get ELIGIBLE status // Passed projects get ELIGIBLE status (or advance to next round)
if (passedIds.length > 0) { if (passedIds.length > 0) {
if (nextRound) {
// Advance passed projects to next round
operations.push( operations.push(
ctx.prisma.roundProject.updateMany({ ctx.prisma.project.updateMany({
where: { roundId: input.roundId, projectId: { in: passedIds } }, where: { roundId: input.roundId, id: { in: passedIds } },
data: { status: 'ELIGIBLE' }, data: { roundId: nextRound.id, status: 'SUBMITTED' },
}) })
) )
} else {
// If there's a next round, advance passed projects to it // No next round, just mark as eligible
if (nextRound) {
operations.push( operations.push(
ctx.prisma.roundProject.createMany({ ctx.prisma.project.updateMany({
data: passedIds.map((projectId) => ({ where: { roundId: input.roundId, id: { in: passedIds } },
roundId: nextRound.id, data: { status: 'ELIGIBLE' },
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
}) })
) )
} }
@ -837,9 +824,9 @@ export const filteringRouter = router({
}, },
}) })
// Restore RoundProject status // Restore project status
await ctx.prisma.roundProject.updateMany({ await ctx.prisma.project.updateMany({
where: { roundId: input.roundId, projectId: input.projectId }, where: { roundId: input.roundId, id: input.projectId },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}) })
@ -883,8 +870,8 @@ export const filteringRouter = router({
}, },
}) })
), ),
ctx.prisma.roundProject.updateMany({ ctx.prisma.project.updateMany({
where: { roundId: input.roundId, projectId: { in: input.projectIds } }, where: { roundId: input.roundId, id: { in: input.projectIds } },
data: { status: 'ELIGIBLE' }, data: { status: 'ELIGIBLE' },
}), }),
]) ])

View File

@ -82,11 +82,7 @@ export const learningResourceRouter = router({
include: { include: {
project: { project: {
select: { select: {
roundProjects: { status: true,
select: { status: true },
orderBy: { addedAt: 'desc' },
take: 1,
},
}, },
}, },
}, },
@ -95,12 +91,12 @@ export const learningResourceRouter = router({
// Determine highest cohort level // Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
const rpStatus = assignment.project.roundProjects[0]?.status const projectStatus = assignment.project.status
if (rpStatus === 'FINALIST') { if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (rpStatus === 'SEMIFINALIST') { if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }
@ -166,11 +162,7 @@ export const learningResourceRouter = router({
include: { include: {
project: { project: {
select: { select: {
roundProjects: { status: true,
select: { status: true },
orderBy: { addedAt: 'desc' as const },
take: 1,
},
}, },
}, },
}, },
@ -178,12 +170,12 @@ export const learningResourceRouter = router({
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
const rpStatus = assignment.project.roundProjects[0]?.status const projectStatus = assignment.project.status
if (rpStatus === 'FINALIST') { if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (rpStatus === 'SEMIFINALIST') { if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }
@ -241,11 +233,7 @@ export const learningResourceRouter = router({
include: { include: {
project: { project: {
select: { select: {
roundProjects: { status: true,
select: { status: true },
orderBy: { addedAt: 'desc' as const },
take: 1,
},
}, },
}, },
}, },
@ -253,12 +241,12 @@ export const learningResourceRouter = router({
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL' let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) { for (const assignment of assignments) {
const rpStatus = assignment.project.roundProjects[0]?.status const projectStatus = assignment.project.status
if (rpStatus === 'FINALIST') { if (projectStatus === 'FINALIST') {
userCohortLevel = 'FINALIST' userCohortLevel = 'FINALIST'
break break
} }
if (rpStatus === 'SEMIFINALIST') { if (projectStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST' userCohortLevel = 'SEMIFINALIST'
} }
} }

View File

@ -15,17 +15,13 @@ export const liveVotingRouter = router({
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
roundProjects: { projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
select: { id: true, title: true, teamName: true }, select: { id: true, title: true, teamName: true },
}, },
}, },
}, },
}, },
},
},
}) })
if (!session) { if (!session) {
@ -38,17 +34,13 @@ export const liveVotingRouter = router({
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
roundProjects: { projects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } }, where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
include: {
project: {
select: { id: true, title: true, teamName: true }, select: { id: true, title: true, teamName: true },
}, },
}, },
}, },
}, },
},
},
}) })
} }

View File

@ -410,7 +410,7 @@ export const mentorRouter = router({
// Get projects without mentors // Get projects without mentors
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
roundProjects: { some: { roundId: input.roundId } }, roundId: input.roundId,
mentorAssignment: null, mentorAssignment: null,
wantsMentorship: true, wantsMentorship: true,
}, },
@ -548,19 +548,12 @@ export const mentorRouter = router({
where: { mentorId: ctx.user.id }, where: { mentorId: ctx.user.id },
include: { include: {
project: { project: {
include: {
program: { select: { name: true, year: true } },
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { name: true, year: true } }, program: { select: { name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: { teamMembers: {
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true, email: true } },
@ -601,19 +594,12 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({ const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId }, where: { id: input.projectId },
include: {
program: { select: { id: true, name: true, year: true } },
roundProjects: {
include: { include: {
round: { round: {
include: { include: {
program: { select: { id: true, name: true, year: true } }, program: { select: { id: true, name: true, year: true } },
}, },
}, },
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: { teamMembers: {
include: { include: {
user: { user: {
@ -660,7 +646,7 @@ export const mentorRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where = { const where = {
...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }), ...(input.roundId && { project: { roundId: input.roundId } }),
...(input.mentorId && { mentorId: input.mentorId }), ...(input.mentorId && { mentorId: input.mentorId }),
} }
@ -675,10 +661,7 @@ export const mentorRouter = router({
teamName: true, teamName: true,
oceanIssue: true, oceanIssue: true,
competitionCategory: true, competitionCategory: true,
roundProjects: { status: true,
select: { status: true },
take: 1,
},
}, },
}, },
mentor: { mentor: {

View File

@ -171,9 +171,10 @@ export const notionImportRouter = router({
} }
// Create project // Create project
const createdProject = await ctx.prisma.project.create({ await ctx.prisma.project.create({
data: { data: {
programId: round.programId, roundId: round.id,
status: 'SUBMITTED',
title: title.trim(), title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
@ -186,15 +187,6 @@ export const notionImportRouter = router({
}, },
}) })
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED',
},
})
results.imported++ results.imported++
} catch (error) { } catch (error) {
results.errors.push({ results.errors.push({

View File

@ -1,433 +0,0 @@
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'
import {
createNotification,
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// 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,
},
})
// In-app notification for applicant
const programName = form.program?.name || form.round?.name || 'the program'
await createNotification({
userId: contactUser.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${input.projectName}" has been successfully submitted.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: input.projectName,
programName,
},
})
// Notify admins of new application
await notifyAdmins({
type: NotificationTypes.NEW_APPLICATION,
title: 'New Application',
message: `New application received: "${input.projectName}" from ${input.contactName}.`,
linkUrl: `/admin/projects/${project.id}`,
linkLabel: 'Review Application',
metadata: {
projectName: input.projectName,
applicantName: input.contactName,
applicantEmail: input.contactEmail,
programName,
},
})
return {
success: true,
projectId: project.id,
confirmationMessage: form.confirmationMessage,
}
}),
})

View File

@ -20,14 +20,14 @@ export const programRouter = router({
_count: { _count: {
select: { rounds: true }, select: { rounds: true },
}, },
rounds: input?.includeRounds ? { rounds: {
orderBy: { sortOrder: 'asc' }, orderBy: { createdAt: 'asc' },
include: { include: {
_count: { _count: {
select: { roundProjects: true, assignments: true }, select: { projects: true, assignments: true },
},
}, },
}, },
} : false,
}, },
}) })
}), }),
@ -45,7 +45,7 @@ export const programRouter = router({
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
include: { include: {
_count: { _count: {
select: { roundProjects: true, assignments: true }, select: { projects: true, assignments: true },
}, },
}, },
}, },

View File

@ -69,48 +69,29 @@ export const projectRouter = router({
// Build where clause // Build where clause
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (programId) where.programId = programId // Filter by program via round
if (programId) where.round = { programId }
// Filter by round via RoundProject join // Filter by round
if (roundId) { if (roundId) {
where.roundProjects = { some: { roundId } } where.roundId = roundId
} }
// Exclude projects already in a specific round // Exclude projects in a specific round
if (notInRoundId) { if (notInRoundId) {
where.roundProjects = { where.roundId = { not: notInRoundId }
...(where.roundProjects as Record<string, unknown> || {}),
none: { roundId: notInRoundId },
}
} }
// Filter by unassigned (not in any round) // Filter by unassigned (no round)
if (unassignedOnly) { if (unassignedOnly) {
where.roundProjects = { none: {} } where.roundId = null
} }
// Status filter via RoundProject // Status filter
if (roundId && (statuses?.length || status)) { if (statuses?.length || status) {
const statusValues = statuses?.length ? statuses : status ? [status] : [] const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) { if (statusValues.length > 0) {
where.roundProjects = { where.status = { in: statusValues }
some: {
roundId,
status: { in: statusValues },
},
}
}
} else if (statuses?.length || status) {
// Status filter without specific round — match any round with that status
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
where.roundProjects = {
...(where.roundProjects as Record<string, unknown> || {}),
some: {
...((where.roundProjects as Record<string, unknown>)?.some as Record<string, unknown> || {}),
status: { in: statusValues },
},
}
} }
} }
@ -150,17 +131,13 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
files: true, files: true,
program: {
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: { round: {
select: { id: true, name: true, sortOrder: true }, select: {
id: true,
name: true,
program: { select: { id: true, name: true, year: true } },
}, },
}, },
orderBy: { addedAt: 'desc' },
},
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
}), }),
@ -183,8 +160,8 @@ export const projectRouter = router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([ const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({ ctx.prisma.round.findMany({
select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } }, select: { id: true, name: true, program: { select: { name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }], orderBy: [{ program: { year: 'desc' } }, { createdAt: 'asc' }],
}), }),
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where: { country: { not: null } }, where: { country: { not: null } },
@ -228,17 +205,7 @@ export const projectRouter = router({
where: { id: input.id }, where: { id: input.id },
include: { include: {
files: true, files: true,
program: { round: true,
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: {
select: { id: true, name: true, sortOrder: true, status: true },
},
},
orderBy: { round: { sortOrder: 'asc' } },
},
teamMembers: { teamMembers: {
include: { include: {
user: { user: {
@ -307,13 +274,12 @@ export const projectRouter = router({
/** /**
* Create a single project (admin only) * Create a single project (admin only)
* Projects belong to a program. Optionally assign to a round immediately. * Projects belong to a round.
*/ */
create: adminProcedure create: adminProcedure
.input( .input(
z.object({ z.object({
programId: z.string(), roundId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500), title: z.string().min(1).max(500),
teamName: z.string().optional(), teamName: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
@ -322,24 +288,14 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { metadataJson, roundId, ...rest } = input const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.project.create({
data: { data: {
...rest, ...rest,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
},
})
// If roundId provided, also create RoundProject entry
if (roundId) {
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED', status: 'SUBMITTED',
}, },
}) })
}
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
@ -348,7 +304,7 @@ export const projectRouter = router({
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: project.id,
detailsJson: { title: input.title, programId: input.programId, roundId }, detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -391,22 +347,20 @@ export const projectRouter = router({
where: { id }, where: { id },
data: { data: {
...data, ...data,
...(status && { status }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })
// Update status on RoundProject if both status and roundId provided // Send notifications if status changed
if (status && roundId) { if (status) {
await ctx.prisma.roundProject.updateMany({ // Get round details for notification
where: { projectId: id, roundId }, const projectWithRound = await ctx.prisma.project.findUnique({
data: { status }, where: { id },
include: { round: { select: { name: true, entryNotificationType: true, program: { select: { name: true } } } } },
}) })
// Get round details including configured notification type const round = projectWithRound?.round
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
})
// Helper to get notification title based on type // Helper to get notification title based on type
const getNotificationTitle = (type: string): string => { const getNotificationTitle = (type: string): string => {
@ -445,7 +399,7 @@ export const projectRouter = router({
programName: round.program?.name, programName: round.program?.name,
}, },
}) })
} else { } else if (round) {
// Fall back to hardcoded status-based notifications // Fall back to hardcoded status-based notifications
const notificationConfig: Record< const notificationConfig: Record<
string, string,
@ -494,7 +448,7 @@ export const projectRouter = router({
action: 'UPDATE', action: 'UPDATE',
entityType: 'Project', entityType: 'Project',
entityId: id, entityId: id,
detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue, detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
@ -570,12 +524,13 @@ export const projectRouter = router({
// Create projects in a transaction // Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => { const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects // Create all projects with roundId
const projectData = input.projects.map((p) => { const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p const { metadataJson, ...rest } = p
return { return {
...rest, ...rest,
programId: input.programId, roundId: input.roundId!,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
} }
}) })
@ -585,17 +540,6 @@ export const projectRouter = router({
select: { id: true }, select: { id: true },
}) })
// If roundId provided, create RoundProject entries
if (input.roundId) {
await tx.roundProject.createMany({
data: created.map((p) => ({
roundId: input.roundId!,
projectId: p.id,
status: 'SUBMITTED' as const,
})),
})
}
return { imported: created.length } return { imported: created.length }
}) })
@ -624,8 +568,8 @@ export const projectRouter = router({
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
if (input.programId) where.programId = input.programId if (input.programId) where.round = { programId: input.programId }
if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } } if (input.roundId) where.roundId = input.roundId
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined, where: Object.keys(where).length > 0 ? where : undefined,
@ -658,9 +602,9 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.roundProject.updateMany({ const updated = await ctx.prisma.project.updateMany({
where: { where: {
projectId: { in: input.ids }, id: { in: input.ids },
roundId: input.roundId, roundId: input.roundId,
}, },
data: { status: input.status }, data: { status: input.status },
@ -798,8 +742,8 @@ export const projectRouter = router({
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
programId, round: { programId },
roundProjects: { none: {} }, roundId: null,
} }
if (search) { if (search) {

View File

@ -19,7 +19,7 @@ export const roundRouter = router({
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
include: { include: {
_count: { _count: {
select: { roundProjects: true, assignments: true }, select: { projects: true, assignments: true },
}, },
}, },
}) })
@ -36,7 +36,7 @@ export const roundRouter = router({
include: { include: {
program: true, program: true,
_count: { _count: {
select: { roundProjects: true, assignments: true }, select: { projects: true, assignments: true },
}, },
evaluationForms: { evaluationForms: {
where: { isActive: true }, where: { isActive: true },
@ -113,24 +113,19 @@ export const roundRouter = router({
}, },
}) })
// For FILTERING rounds, automatically add all projects from the program // For FILTERING rounds, automatically move all projects from the program to this round
if (input.roundType === 'FILTERING') { if (input.roundType === 'FILTERING') {
const projects = await ctx.prisma.project.findMany({ await ctx.prisma.project.updateMany({
where: { programId: input.programId }, where: {
select: { id: true }, round: { programId: input.programId },
}) roundId: { not: round.id },
},
if (projects.length > 0) { data: {
await ctx.prisma.roundProject.createMany({
data: projects.map((p) => ({
roundId: round.id, roundId: round.id,
projectId: p.id,
status: 'SUBMITTED', status: 'SUBMITTED',
})), },
skipDuplicates: true,
}) })
} }
}
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
@ -341,7 +336,7 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] = const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([ await Promise.all([
ctx.prisma.roundProject.count({ where: { roundId: input.id } }), ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }), ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true }, where: { roundId: input.id, isCompleted: true },
@ -472,7 +467,7 @@ export const roundRouter = router({
const round = await ctx.prisma.round.findUniqueOrThrow({ const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id }, where: { id: input.id },
include: { include: {
_count: { select: { roundProjects: true, assignments: true } }, _count: { select: { projects: true, assignments: true } },
}, },
}) })
@ -490,7 +485,7 @@ export const roundRouter = router({
detailsJson: { detailsJson: {
name: round.name, name: round.name,
status: round.status, status: round.status,
projectsDeleted: round._count.roundProjects, projectsDeleted: round._count.projects,
assignmentsDeleted: round._count.assignments, assignmentsDeleted: round._count.assignments,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
@ -532,29 +527,25 @@ export const roundRouter = router({
where: { id: input.roundId }, where: { id: input.roundId },
}) })
// Verify all projects belong to the same program // Update projects to assign them to this round
const projects = await ctx.prisma.project.findMany({ const updated = await ctx.prisma.project.updateMany({
where: { id: { in: input.projectIds }, programId: round.programId }, where: {
select: { id: true }, id: { in: input.projectIds },
round: { programId: round.programId },
},
data: {
roundId: input.roundId,
status: 'SUBMITTED',
},
}) })
if (projects.length !== input.projectIds.length) { if (updated.count === 0) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Some projects do not belong to this program', message: 'No projects were assigned. Projects may not belong to this program.',
}) })
} }
// Create RoundProject entries (skip duplicates)
const created = await ctx.prisma.roundProject.createMany({
data: input.projectIds.map((projectId) => ({
roundId: input.roundId,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { data: {
@ -562,13 +553,13 @@ export const roundRouter = router({
action: 'ASSIGN_PROJECTS_TO_ROUND', action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round', entityType: 'Round',
entityId: input.roundId, entityId: input.roundId,
detailsJson: { projectCount: created.count }, detailsJson: { projectCount: updated.count },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, },
}) })
return { assigned: created.count } return { assigned: updated.count }
}), }),
/** /**
@ -582,12 +573,17 @@ export const roundRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const deleted = await ctx.prisma.roundProject.deleteMany({ // Set roundId to null for these projects (remove from round)
const updated = await ctx.prisma.project.updateMany({
where: { where: {
roundId: input.roundId, roundId: input.roundId,
projectId: { in: input.projectIds }, id: { in: input.projectIds },
},
data: {
roundId: null as unknown as string, // Projects need to be orphaned
}, },
}) })
const deleted = { count: updated.count }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
@ -632,12 +628,12 @@ export const roundRouter = router({
} }
// Verify all projects are in the source round // Verify all projects are in the source round
const sourceProjects = await ctx.prisma.roundProject.findMany({ const sourceProjects = await ctx.prisma.project.findMany({
where: { where: {
roundId: input.fromRoundId, roundId: input.fromRoundId,
projectId: { in: input.projectIds }, id: { in: input.projectIds },
}, },
select: { projectId: true }, select: { id: true },
}) })
if (sourceProjects.length !== input.projectIds.length) { if (sourceProjects.length !== input.projectIds.length) {
@ -647,15 +643,18 @@ export const roundRouter = router({
}) })
} }
// Create entries in target round (skip duplicates) // Move projects to target round
const created = await ctx.prisma.roundProject.createMany({ const updated = await ctx.prisma.project.updateMany({
data: input.projectIds.map((projectId) => ({ where: {
id: { in: input.projectIds },
roundId: input.fromRoundId,
},
data: {
roundId: input.toRoundId, roundId: input.toRoundId,
projectId, status: 'SUBMITTED',
status: 'SUBMITTED' as const, },
})),
skipDuplicates: true,
}) })
const created = { count: updated.count }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({

View File

@ -237,13 +237,11 @@ export const specialAwardRouter = router({
const statusFilter = input.includeSubmitted const statusFilter = input.includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) ? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const) : (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
const roundProjectEntries = await ctx.prisma.roundProject.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
round: { programId: award.programId }, round: { programId: award.programId },
status: { in: [...statusFilter] }, status: { in: [...statusFilter] },
}, },
include: {
project: {
select: { select: {
id: true, id: true,
title: true, title: true,
@ -254,12 +252,7 @@ export const specialAwardRouter = router({
tags: true, tags: true,
oceanIssue: true, oceanIssue: true,
}, },
},
},
}) })
// Deduplicate projects (same project may be in multiple rounds)
const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project]))
const projects = Array.from(projectMap.values())
if (projects.length === 0) { if (projects.length === 0) {
throw new TRPCError({ throw new TRPCError({

View File

@ -1,6 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc' import { router, adminProcedure, protectedProcedure } from '../trpc'
import {
tagProject,
batchTagProjects,
getTagSuggestions,
addProjectTag,
removeProjectTag,
} from '../services/ai-tagging'
export const tagRouter = router({ export const tagRouter = router({
/** /**
@ -391,6 +398,157 @@ export const tagRouter = router({
) )
) )
return { success: true }
}),
// ═══════════════════════════════════════════════════════════════════════════
// PROJECT TAGGING (AI-powered)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get tags for a project
*/
getProjectTags: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const tags = await ctx.prisma.projectTag.findMany({
where: { projectId: input.projectId },
include: { tag: true },
orderBy: { confidence: 'desc' },
})
return tags.map((pt) => ({
id: pt.id,
tagId: pt.tagId,
name: pt.tag.name,
category: pt.tag.category,
color: pt.tag.color,
confidence: pt.confidence,
source: pt.source,
createdAt: pt.createdAt,
}))
}),
/**
* Get AI tag suggestions for a project (without applying)
*/
getSuggestions: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const suggestions = await getTagSuggestions(input.projectId, ctx.user.id)
return suggestions
}),
/**
* Tag a single project with AI
*/
tagProject: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await tagProject(input.projectId, ctx.user.id)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'AI_TAG',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
applied: result.applied.map((t) => t.tagName),
tokensUsed: result.tokensUsed,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
}),
/**
* Batch tag all untagged projects in a round
*/
batchTagProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await batchTagProjects(input.roundId, ctx.user.id)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BATCH_AI_TAG',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
processed: result.processed,
failed: result.failed,
skipped: result.skipped,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
}),
/**
* Manually add a tag to a project
*/
addProjectTag: adminProcedure
.input(
z.object({
projectId: z.string(),
tagId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await addProjectTag(input.projectId, input.tagId)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ADD_TAG',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
/**
* Remove a tag from a project
*/
removeProjectTag: adminProcedure
.input(
z.object({
projectId: z.string(),
tagId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
await removeProjectTag(input.projectId, input.tagId)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REMOVE_TAG',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true } return { success: true }
}), }),
}) })

View File

@ -199,9 +199,10 @@ export const typeformImportRouter = router({
} }
// Create project // Create project
const createdProject = await ctx.prisma.project.create({ await ctx.prisma.project.create({
data: { data: {
programId: round.programId, roundId: round.id,
status: 'SUBMITTED',
title: String(title).trim(), title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
@ -214,15 +215,6 @@ export const typeformImportRouter = router({
}, },
}) })
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED',
},
})
results.imported++ results.imported++
} catch (error) { } catch (error) {
results.errors.push({ results.errors.push({

View File

@ -29,6 +29,7 @@ export const userRouter = router({
expertiseTags: true, expertiseTags: true,
metadataJson: true, metadataJson: true,
phoneNumber: true, phoneNumber: true,
country: true,
notificationPreference: true, notificationPreference: true,
profileImageKey: true, profileImageKey: true,
createdAt: true, createdAt: true,
@ -415,6 +416,7 @@ export const userRouter = router({
/** /**
* Bulk import users (admin only) * Bulk import users (admin only)
* Optionally pre-assign projects to jury members during invitation
*/ */
bulkCreate: adminProcedure bulkCreate: adminProcedure
.input( .input(
@ -425,6 +427,15 @@ export const userRouter = router({
name: z.string().optional(), name: z.string().optional(),
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members
assignments: z
.array(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.optional(),
}) })
), ),
}) })
@ -456,10 +467,20 @@ export const userRouter = router({
return { created: 0, skipped } return { created: 0, skipped }
} }
// Build map of email -> assignments before createMany (since createMany removes extra fields)
const emailToAssignments = new Map<string, Array<{ projectId: string; roundId: string }>>()
for (const u of newUsers) {
if (u.assignments && u.assignments.length > 0) {
emailToAssignments.set(u.email.toLowerCase(), u.assignments)
}
}
const created = await ctx.prisma.user.createMany({ const created = await ctx.prisma.user.createMany({
data: newUsers.map((u) => ({ data: newUsers.map((u) => ({
...u,
email: u.email.toLowerCase(), email: u.email.toLowerCase(),
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
status: 'INVITED', status: 'INVITED',
})), })),
}) })
@ -483,6 +504,44 @@ export const userRouter = router({
select: { id: true, email: true, name: true, role: true }, select: { id: true, email: true, name: true, role: true },
}) })
// Create pre-assignments for users who have them
let assignmentsCreated = 0
for (const user of createdUsers) {
const assignments = emailToAssignments.get(user.email.toLowerCase())
if (assignments && assignments.length > 0) {
for (const assignment of assignments) {
try {
await ctx.prisma.assignment.create({
data: {
userId: user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
method: 'MANUAL',
createdBy: ctx.user.id,
},
})
assignmentsCreated++
} catch {
// Skip if assignment already exists (shouldn't happen for new users)
}
}
}
}
// Audit log for assignments if any were created
if (assignmentsCreated > 0) {
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
}
let emailsSent = 0 let emailsSent = 0
const emailErrors: string[] = [] const emailErrors: string[] = []
@ -525,7 +584,7 @@ export const userRouter = router({
} }
} }
return { created: created.count, skipped, emailsSent, emailErrors } return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
}), }),
/** /**
@ -729,6 +788,7 @@ export const userRouter = router({
z.object({ z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
phoneNumber: z.string().optional(), phoneNumber: z.string().optional(),
country: z.string().optional(),
expertiseTags: z.array(z.string()).optional(), expertiseTags: z.array(z.string()).optional(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
}) })
@ -750,6 +810,7 @@ export const userRouter = router({
data: { data: {
name: input.name, name: input.name,
phoneNumber: input.phoneNumber, phoneNumber: input.phoneNumber,
country: input.country,
expertiseTags: mergedTags, expertiseTags: mergedTags,
notificationPreference: input.notificationPreference || 'EMAIL', notificationPreference: input.notificationPreference || 'EMAIL',
onboardingCompletedAt: new Date(), onboardingCompletedAt: new Date(),
@ -782,8 +843,8 @@ export const userRouter = router({
select: { onboardingCompletedAt: true, role: true }, select: { onboardingCompletedAt: true, role: true },
}) })
// Jury members and mentors need onboarding // Jury members, mentors, and admins need onboarding
const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR'] const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN']
if (!rolesRequiringOnboarding.includes(user.role)) { if (!rolesRequiringOnboarding.includes(user.role)) {
return false return false
} }

View File

@ -0,0 +1,541 @@
/**
* AI-Powered Project Tagging Service
*
* Analyzes projects and assigns expertise tags automatically.
*
* Features:
* - Single project tagging (on-submit or manual)
* - Batch tagging for rounds
* - Confidence scores for each tag
* - Additive only - never removes existing tags
*
* GDPR Compliance:
* - All project data is anonymized before AI processing
* - Only necessary fields sent to OpenAI
* - No personal identifiers in prompts or responses
*/
import { prisma } from '@/lib/prisma'
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import {
anonymizeProjectsForAI,
validateAnonymizedProjects,
type ProjectWithRelations,
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TagSuggestion {
tagId: string
tagName: string
confidence: number
reasoning: string
}
export interface TaggingResult {
projectId: string
suggestions: TagSuggestion[]
applied: TagSuggestion[]
tokensUsed: number
}
export interface BatchTaggingResult {
processed: number
failed: number
skipped: number
errors: string[]
results: TaggingResult[]
}
interface AvailableTag {
id: string
name: string
category: string | null
description: string | null
}
// ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULT_BATCH_SIZE = 10
const MAX_BATCH_SIZE = 25
const CONFIDENCE_THRESHOLD = 0.5
const DEFAULT_MAX_TAGS = 5
// System prompt optimized for tag suggestion
const TAG_SUGGESTION_SYSTEM_PROMPT = `You are an expert at categorizing ocean conservation and sustainability projects.
Analyze the project and suggest the most relevant expertise tags from the provided list.
Consider the project's focus areas, technology, methodology, and domain.
Return JSON with this format:
{
"suggestions": [
{
"tag_name": "exact tag name from list",
"confidence": 0.0-1.0,
"reasoning": "brief explanation why this tag fits"
}
]
}
Rules:
- Only suggest tags from the provided list (exact names)
- Order by relevance (most relevant first)
- Confidence should reflect how well the tag matches
- Maximum 7 suggestions per project
- Be conservative - only suggest tags that truly apply`
// ─── Helper Functions ────────────────────────────────────────────────────────
/**
* Get system settings for AI tagging
*/
async function getTaggingSettings(): Promise<{
enabled: boolean
maxTags: number
}> {
const settings = await prisma.systemSettings.findMany({
where: {
key: {
in: ['ai_tagging_enabled', 'ai_tagging_max_tags'],
},
},
})
const settingsMap = new Map(settings.map((s) => [s.key, s.value]))
return {
enabled: settingsMap.get('ai_tagging_enabled') === 'true',
maxTags: parseInt(settingsMap.get('ai_tagging_max_tags') || String(DEFAULT_MAX_TAGS)),
}
}
/**
* Get all active expertise tags
*/
async function getAvailableTags(): Promise<AvailableTag[]> {
return prisma.expertiseTag.findMany({
where: { isActive: true },
select: {
id: true,
name: true,
category: true,
description: true,
},
orderBy: [{ category: 'asc' }, { sortOrder: 'asc' }],
})
}
/**
* Convert project to format for anonymization
*/
function toProjectWithRelations(project: {
id: string
title: string
description?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
geographicZone?: string | null
institution?: string | null
tags: string[]
foundedAt?: Date | null
wantsMentorship?: boolean
submissionSource?: string
submittedAt?: Date | null
_count?: { teamMembers?: number; files?: number }
files?: Array<{ fileType: string | null }>
}): ProjectWithRelations {
return {
id: project.id,
title: project.title,
description: project.description,
competitionCategory: project.competitionCategory as any,
oceanIssue: project.oceanIssue as any,
country: project.country,
geographicZone: project.geographicZone,
institution: project.institution,
tags: project.tags,
foundedAt: project.foundedAt,
wantsMentorship: project.wantsMentorship ?? false,
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
submittedAt: project.submittedAt,
_count: {
teamMembers: project._count?.teamMembers ?? 0,
files: project._count?.files ?? 0,
},
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
}
}
// ─── AI Tagging Core ─────────────────────────────────────────────────────────
/**
* Call OpenAI to get tag suggestions for a project
*/
async function getAISuggestions(
anonymizedProject: AnonymizedProjectForAI,
availableTags: AvailableTag[],
userId?: string
): Promise<{ suggestions: TagSuggestion[]; tokensUsed: number }> {
const openai = await getOpenAI()
if (!openai) {
console.warn('[AI Tagging] OpenAI not configured')
return { suggestions: [], tokensUsed: 0 }
}
const model = await getConfiguredModel()
// Build tag list for prompt
const tagList = availableTags.map((t) => ({
name: t.name,
category: t.category,
description: t.description,
}))
const userPrompt = `PROJECT:
${JSON.stringify(anonymizedProject, null, 2)}
AVAILABLE TAGS:
${JSON.stringify(tagList, null, 2)}
Suggest relevant tags for this project.`
try {
const params = buildCompletionParams(model, {
messages: [
{ role: 'system', content: TAG_SUGGESTION_SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
jsonMode: true,
temperature: 0.3,
maxTokens: 2000,
})
const response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
// Log usage
await logAIUsage({
userId,
action: 'PROJECT_TAGGING',
entityType: 'Project',
entityId: anonymizedProject.project_id,
model,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
batchSize: 1,
itemsProcessed: 1,
status: 'SUCCESS',
})
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
const parsed = JSON.parse(content) as {
suggestions: Array<{
tag_name: string
confidence: number
reasoning: string
}>
}
// Map to TagSuggestion format, matching tag names to IDs
const suggestions: TagSuggestion[] = []
for (const s of parsed.suggestions || []) {
const tag = availableTags.find(
(t) => t.name.toLowerCase() === s.tag_name.toLowerCase()
)
if (tag) {
suggestions.push({
tagId: tag.id,
tagName: tag.name,
confidence: Math.max(0, Math.min(1, s.confidence)),
reasoning: s.reasoning,
})
}
}
return { suggestions, tokensUsed: usage.totalTokens }
} catch (error) {
if (error instanceof SyntaxError) {
const parseError = createParseError(error.message)
logAIError('Tagging', 'getAISuggestions', parseError)
}
await logAIUsage({
userId,
action: 'PROJECT_TAGGING',
entityType: 'Project',
entityId: anonymizedProject.project_id,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
batchSize: 1,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Tag a single project with AI-suggested expertise tags
*
* Behavior:
* - Only applies tags with confidence >= 0.5
* - Additive only - never removes existing tags
* - Respects maxTags setting
*/
export async function tagProject(
projectId: string,
userId?: string
): Promise<TaggingResult> {
const settings = await getTaggingSettings()
if (!settings.enabled) {
return {
projectId,
suggestions: [],
applied: [],
tokensUsed: 0,
}
}
// Fetch project with needed fields
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
projectTags: true,
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
if (!project) {
throw new Error(`Project not found: ${projectId}`)
}
// Get available tags
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {
return {
projectId,
suggestions: [],
applied: [],
tokensUsed: 0,
}
}
// Anonymize project data
const projectWithRelations = toProjectWithRelations(project)
const { anonymized, mappings } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
// Validate anonymization
if (!validateAnonymizedProjects(anonymized)) {
throw new Error('GDPR compliance check failed: PII detected in anonymized data')
}
// Get AI suggestions
const { suggestions, tokensUsed } = await getAISuggestions(
anonymized[0],
availableTags,
userId
)
// Filter by confidence threshold
const validSuggestions = suggestions.filter(
(s) => s.confidence >= CONFIDENCE_THRESHOLD
)
// Get existing tag IDs to avoid duplicates
const existingTagIds = new Set(project.projectTags.map((pt) => pt.tagId))
// Calculate how many more tags we can add
const currentTagCount = project.projectTags.length
const remainingSlots = Math.max(0, settings.maxTags - currentTagCount)
// Filter out existing tags and limit to remaining slots
const newSuggestions = validSuggestions
.filter((s) => !existingTagIds.has(s.tagId))
.slice(0, remainingSlots)
// Apply new tags
const applied: TagSuggestion[] = []
for (const suggestion of newSuggestions) {
try {
await prisma.projectTag.create({
data: {
projectId,
tagId: suggestion.tagId,
confidence: suggestion.confidence,
source: 'AI',
},
})
applied.push(suggestion)
} catch (error) {
// Skip if tag already exists (race condition)
console.warn(`[AI Tagging] Failed to apply tag ${suggestion.tagName}: ${error}`)
}
}
return {
projectId,
suggestions,
applied,
tokensUsed,
}
}
/**
* Batch tag all untagged projects in a round
*
* Only processes projects with zero tags.
*/
export async function batchTagProjects(
roundId: string,
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const settings = await getTaggingSettings()
if (!settings.enabled) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: ['AI tagging is disabled'],
results: [],
}
}
// Get untagged projects in round
const projects = await prisma.project.findMany({
where: {
roundId,
projectTags: { none: {} }, // Only projects with no tags
},
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
if (projects.length === 0) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [],
results: [],
}
}
const results: TaggingResult[] = []
let processed = 0
let failed = 0
const errors: string[] = []
for (let i = 0; i < projects.length; i++) {
const project = projects[i]
try {
const result = await tagProject(project.id, userId)
results.push(result)
processed++
} catch (error) {
failed++
errors.push(`${project.title}: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// Report progress
if (onProgress) {
onProgress(i + 1, projects.length)
}
}
return {
processed,
failed,
skipped: 0,
errors,
results,
}
}
/**
* Get tag suggestions for a project without applying them
* Useful for preview/review before applying
*/
export async function getTagSuggestions(
projectId: string,
userId?: string
): Promise<TagSuggestion[]> {
// Fetch project
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
if (!project) {
throw new Error(`Project not found: ${projectId}`)
}
// Get available tags
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {
return []
}
// Anonymize project data
const projectWithRelations = toProjectWithRelations(project)
const { anonymized } = anonymizeProjectsForAI([projectWithRelations], 'FILTERING')
// Validate anonymization
if (!validateAnonymizedProjects(anonymized)) {
throw new Error('GDPR compliance check failed')
}
// Get AI suggestions
const { suggestions } = await getAISuggestions(anonymized[0], availableTags, userId)
return suggestions
}
/**
* Manually add a tag to a project
*/
export async function addProjectTag(
projectId: string,
tagId: string
): Promise<void> {
await prisma.projectTag.upsert({
where: { projectId_tagId: { projectId, tagId } },
create: { projectId, tagId, source: 'MANUAL', confidence: 1.0 },
update: { source: 'MANUAL', confidence: 1.0 },
})
}
/**
* Remove a tag from a project
*/
export async function removeProjectTag(
projectId: string,
tagId: string
): Promise<void> {
await prisma.projectTag.deleteMany({
where: { projectId, tagId },
})
}

View File

@ -0,0 +1,381 @@
/**
* Smart Assignment Scoring Service
*
* Calculates scores for jury/mentor-project matching based on:
* - Tag overlap (expertise match)
* - Workload balance
* - Country match (mentors only)
*
* Score Breakdown (100 points max):
* - Tag overlap: 0-50 points (weighted by confidence)
* - Workload balance: 0-25 points
* - Country match: 0-15 points (mentors only)
* - Reserved: 0-10 points (future AI boost)
*/
import { prisma } from '@/lib/prisma'
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ScoreBreakdown {
tagOverlap: number
workloadBalance: number
countryMatch: number
aiBoost: number
}
export interface AssignmentScore {
userId: string
userName: string
userEmail: string
projectId: string
projectTitle: string
score: number
breakdown: ScoreBreakdown
reasoning: string[]
matchingTags: string[]
}
export interface ProjectTagData {
tagId: string
tagName: string
confidence: number
}
// ─── Constants ───────────────────────────────────────────────────────────────
const MAX_TAG_OVERLAP_SCORE = 50
const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15
const POINTS_PER_TAG_MATCH = 10
// ─── Scoring Functions ───────────────────────────────────────────────────────
/**
* Calculate tag overlap score between user expertise and project tags
*/
export function calculateTagOverlapScore(
userTagNames: string[],
projectTags: ProjectTagData[]
): { score: number; matchingTags: string[] } {
if (projectTags.length === 0 || userTagNames.length === 0) {
return { score: 0, matchingTags: [] }
}
const userTagSet = new Set(userTagNames.map((t) => t.toLowerCase()))
const matchingTags: string[] = []
let weightedScore = 0
for (const pt of projectTags) {
if (userTagSet.has(pt.tagName.toLowerCase())) {
matchingTags.push(pt.tagName)
// Weight by confidence - higher confidence = more points
weightedScore += POINTS_PER_TAG_MATCH * pt.confidence
}
}
// Cap at max score
const score = Math.min(MAX_TAG_OVERLAP_SCORE, Math.round(weightedScore))
return { score, matchingTags }
}
/**
* Calculate workload balance score
* Full points if under target, decreasing as over target
*/
export function calculateWorkloadScore(
currentAssignments: number,
targetAssignments: number,
maxAssignments?: number | null
): number {
// If user is at or over their personal max, return 0
if (maxAssignments !== null && maxAssignments !== undefined) {
if (currentAssignments >= maxAssignments) {
return 0
}
}
// If under target, full points
if (currentAssignments < targetAssignments) {
return MAX_WORKLOAD_SCORE
}
// Over target - decrease score
const overload = currentAssignments - targetAssignments
return Math.max(0, MAX_WORKLOAD_SCORE - overload * 5)
}
/**
* Calculate country match score (mentors only)
* Same country = bonus points
*/
export function calculateCountryMatchScore(
userCountry: string | null | undefined,
projectCountry: string | null | undefined
): number {
if (!userCountry || !projectCountry) {
return 0
}
// Normalize for comparison
const normalizedUser = userCountry.toLowerCase().trim()
const normalizedProject = projectCountry.toLowerCase().trim()
if (normalizedUser === normalizedProject) {
return MAX_COUNTRY_SCORE
}
return 0
}
// ─── Main Scoring Function ───────────────────────────────────────────────────
/**
* Get smart assignment suggestions for a round
*/
export async function getSmartSuggestions(options: {
roundId: string
type: 'jury' | 'mentor'
limit?: number
aiMaxPerJudge?: number
}): Promise<AssignmentScore[]> {
const { roundId, type, limit = 50, aiMaxPerJudge = 20 } = options
// Get projects in round with their tags
const projects = await prisma.project.findMany({
where: {
roundId,
status: { not: 'REJECTED' },
},
include: {
projectTags: {
include: { tag: true },
},
},
})
if (projects.length === 0) {
return []
}
// Get users of the appropriate role
const role = type === 'jury' ? 'JURY_MEMBER' : 'MENTOR'
const users = await prisma.user.findMany({
where: {
role,
status: 'ACTIVE',
},
include: {
_count: {
select: {
assignments: {
where: { roundId },
},
},
},
},
})
if (users.length === 0) {
return []
}
// Get existing assignments to avoid duplicates
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
})
const assignedPairs = new Set(
existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
)
// Calculate target assignments per user
const targetPerUser = Math.ceil(projects.length / users.length)
// Calculate scores for all user-project pairs
const suggestions: AssignmentScore[] = []
for (const user of users) {
// Skip users at AI max (they won't appear in suggestions)
const currentCount = user._count.assignments
if (currentCount >= aiMaxPerJudge) {
continue
}
for (const project of projects) {
// Skip if already assigned
const pairKey = `${user.id}:${project.id}`
if (assignedPairs.has(pairKey)) {
continue
}
// Get project tags data
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
tagId: pt.tagId,
tagName: pt.tag.name,
confidence: pt.confidence,
}))
// Calculate scores
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
user.expertiseTags,
projectTags
)
const workloadScore = calculateWorkloadScore(
currentCount,
targetPerUser,
user.maxAssignments
)
// Country match only for mentors
const countryScore =
type === 'mentor'
? calculateCountryMatchScore(
(user as any).country, // User might have country field
project.country
)
: 0
const totalScore = tagScore + workloadScore + countryScore
// Build reasoning
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`Expertise match: ${matchingTags.length} tag(s)`)
}
if (workloadScore === MAX_WORKLOAD_SCORE) {
reasoning.push('Available capacity')
} else if (workloadScore > 0) {
reasoning.push('Moderate workload')
}
if (countryScore > 0) {
reasoning.push('Same country')
}
suggestions.push({
userId: user.id,
userName: user.name || 'Unknown',
userEmail: user.email,
projectId: project.id,
projectTitle: project.title,
score: totalScore,
breakdown: {
tagOverlap: tagScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
},
reasoning,
matchingTags,
})
}
}
// Sort by score descending and limit
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
}
/**
* Get mentor suggestions for a specific project
*/
export async function getMentorSuggestionsForProject(
projectId: string,
limit: number = 10
): Promise<AssignmentScore[]> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: {
projectTags: {
include: { tag: true },
},
mentorAssignment: true,
},
})
if (!project) {
throw new Error(`Project not found: ${projectId}`)
}
// Get all active mentors
const mentors = await prisma.user.findMany({
where: {
role: 'MENTOR',
status: 'ACTIVE',
},
include: {
_count: {
select: { mentorAssignments: true },
},
},
})
if (mentors.length === 0) {
return []
}
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
tagId: pt.tagId,
tagName: pt.tag.name,
confidence: pt.confidence,
}))
const targetPerMentor = 5 // Target 5 projects per mentor
const suggestions: AssignmentScore[] = []
for (const mentor of mentors) {
// Skip if already assigned to this project
if (project.mentorAssignment?.mentorId === mentor.id) {
continue
}
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
mentor.expertiseTags,
projectTags
)
const workloadScore = calculateWorkloadScore(
mentor._count.mentorAssignments,
targetPerMentor,
mentor.maxAssignments
)
const countryScore = calculateCountryMatchScore(
(mentor as any).country,
project.country
)
const totalScore = tagScore + workloadScore + countryScore
const reasoning: string[] = []
if (matchingTags.length > 0) {
reasoning.push(`${matchingTags.length} matching expertise tag(s)`)
}
if (countryScore > 0) {
reasoning.push('Same country of origin')
}
if (workloadScore === MAX_WORKLOAD_SCORE) {
reasoning.push('Available capacity')
}
suggestions.push({
userId: mentor.id,
userName: mentor.name || 'Unknown',
userEmail: mentor.email,
projectId: project.id,
projectTitle: project.title,
score: totalScore,
breakdown: {
tagOverlap: tagScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
},
reasoning,
matchingTags,
})
}
return suggestions.sort((a, b) => b.score - a.score).slice(0, limit)
}

View File

@ -16,6 +16,7 @@ export type AIAction =
| 'FILTERING' | 'FILTERING'
| 'AWARD_ELIGIBILITY' | 'AWARD_ELIGIBILITY'
| 'MENTOR_MATCHING' | 'MENTOR_MATCHING'
| 'PROJECT_TAGGING'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR' export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'