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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user