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