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

@@ -46,6 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@@ -83,9 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
roundId,
availableRounds,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@@ -124,6 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
roundId: selectedRoundId ?? undefined,
})
// Store the DB file ID
@@ -303,6 +309,31 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Round
</label>
<Select
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(

View File

@@ -39,10 +39,19 @@ interface ProjectFile {
bucket: string
objectKey: string
version?: number
isLate?: boolean
}
interface RoundGroup {
roundId: string | null
roundName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files: ProjectFile[]
files?: ProjectFile[]
groupedFiles?: RoundGroup[]
projectId?: string
className?: string
}
@@ -83,8 +92,14 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, projectId, className }: FileViewerProps) {
if (files.length === 0) {
export function FileViewer({ files, groupedFiles, projectId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
}
// Render flat view (backward compatible)
if (!files || files.length === 0) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
@@ -121,6 +136,68 @@ export function FileViewer({ files, projectId, className }: FileViewerProps) {
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<File className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files attached</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Sort groups by sortOrder
const sortedGroups = [...groupedFiles].sort((a, b) => a.sortOrder - b.sortOrder)
// Sort files within each group by type order
const fileTypeSortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg">Project Files</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{sortedGroups.map((group) => {
if (group.files.length === 0) return null
const sortedFiles = [...group.files].sort(
(a, b) => fileTypeSortOrder.indexOf(a.fileType) - fileTypeSortOrder.indexOf(b.fileType)
)
return (
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
</Badge>
</div>
{/* Files in this round */}
<div className="space-y-3">
{sortedFiles.map((file) => (
<FileItem key={file.id} file={file} />
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
function FileItem({ file }: { file: ProjectFile }) {
const [showPreview, setShowPreview] = useState(false)
const Icon = getFileIcon(file.fileType, file.mimeType)
@@ -151,10 +228,15 @@ function FileItem({ file }: { file: ProjectFile }) {
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(file.fileType)}
</Badge>
{file.isLate && (
<Badge variant="destructive" className="text-xs">
Late
</Badge>
)}
<span>{formatFileSize(file.size)}</span>
</div>
</div>
@@ -489,7 +571,7 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
// Compact file list for smaller views
export function FileList({ files, className }: FileViewerProps) {
if (files.length === 0) return null
if (!files || files.length === 0) return null
return (
<div className={cn('space-y-2', className)}>