Add dynamic apply wizard customization with admin settings UI

- Create wizard config types, utilities, and defaults (wizard-config.ts)
- Add admin apply settings page with drag-and-drop step ordering, dropdown
  option management, feature toggles, welcome message customization, and
  custom field builder with select/multiselect options editor
- Build dynamic apply wizard component with animated step transitions,
  mobile-first responsive design, and config-driven form validation
- Update step components to accept dynamic config (categories, ocean issues,
  field visibility, feature flags)
- Replace hardcoded enum validation with string-based validation for
  admin-configurable dropdown values, with safe enum casting at storage layer
- Add wizard template system (model, router, admin UI) with built-in
  MOPC Classic preset
- Add program wizard config CRUD procedures to program router
- Update application router getConfig to return wizardConfig, submit handler
  to store custom field data in metadataJson
- Add edition-based apply page, project pool page, and supporting routers
- Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea),
  safe area insets for notched phones, buildStepsArray field visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 13:18:20 +01:00
parent 98fe658c33
commit e7c86a7b1b
40 changed files with 4477 additions and 1045 deletions

View File

@@ -69,7 +69,7 @@ import {
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
@@ -99,6 +99,7 @@ interface ParsedUser {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@@ -265,6 +266,11 @@ export default function MemberInvitePage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
// Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table when navigating back
@@ -393,19 +399,22 @@ export default function MemberInvitePage() {
const name = nameKey ? row[nameKey]?.trim() : undefined
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
const role: Role =
rawRole === 'MENTOR'
rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
return {
email,
name,
role,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !email
? 'No email found'
@@ -413,6 +422,8 @@ export default function MemberInvitePage() {
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -421,7 +432,7 @@ export default function MemberInvitePage() {
},
})
},
[]
[isSuperAdmin]
)
// --- Parse manual rows into ParsedUser format ---
@@ -433,6 +444,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate) seenEmails.add(email)
return {
email,
@@ -440,12 +452,14 @@ export default function MemberInvitePage() {
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@@ -524,6 +538,11 @@ export default function MemberInvitePage() {
<CardTitle>Invite Members</CardTitle>
<CardDescription>
Add members individually or upload a CSV file
{isSuperAdmin && (
<span className="block mt-1 text-primary font-medium">
As a super admin, you can also invite program admins
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -627,6 +646,11 @@ export default function MemberInvitePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">
Program Admin
</SelectItem>
)}
<SelectItem value="JURY_MEMBER">
Jury Member
</SelectItem>