Remove dynamic form builder and complete RoundProject→roundId migration

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:
2026-02-04 14:15:06 +01:00
parent 7bcd2ce6ca
commit 29827268b2
71 changed files with 2139 additions and 6609 deletions

View File

@@ -31,6 +31,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
ArrowRight,
@@ -42,12 +48,19 @@ import {
Plus,
FileSpreadsheet,
UserPlus,
FolderKanban,
ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
roundId: string
}
interface MemberRow {
id: string
name: string
@@ -55,6 +68,7 @@ interface MemberRow {
role: Role
expertiseTags: string[]
tagInput: string
assignments: Assignment[]
}
interface ParsedUser {
@@ -62,6 +76,7 @@ interface ParsedUser {
name?: string
role: Role
expertiseTags?: string[]
assignments?: Assignment[]
isValid: boolean
error?: string
isDuplicate?: boolean
@@ -81,7 +96,7 @@ function nextRowId(): string {
}
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
@@ -115,8 +130,12 @@ export default function MemberInvitePage() {
const [result, setResult] = useState<{
created: number
skipped: number
assignmentsCreated?: number
} | null>(null)
// Pre-assignment state
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
const bulkCreate = trpc.user.bulkCreate.useMutation({
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 ---
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
setRows((prev) =>
@@ -176,6 +222,28 @@ export default function MemberInvitePage() {
).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 ---
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -252,6 +320,7 @@ export default function MemberInvitePage() {
name: r.name.trim() || undefined,
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isDuplicate,
error: !isValidFormat
@@ -298,6 +367,7 @@ export default function MemberInvitePage() {
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
assignments: u.assignments,
})),
})
setSendProgress(100)
@@ -362,6 +432,35 @@ export default function MemberInvitePage() {
{inputMethod === 'manual' ? (
<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 */}
{rows.map((row, index) => (
<div
@@ -500,6 +599,81 @@ export default function MemberInvitePage() {
</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>
))}
@@ -722,6 +896,9 @@ export default function MemberInvitePage() {
{result?.skipped
? ` ${result.skipped} skipped (already exist).`
: ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
: ''}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>