Add file requirements per round and super admin promotion via UI
Part A: File Requirements per Round - New FileRequirement model with name, description, accepted MIME types, max size, required flag, sort order - Added requirementId FK to ProjectFile for linking uploads to requirements - Backend CRUD (create/update/delete/reorder) in file router with audit logging - Mime type validation and team member upload authorization in applicant router - Admin UI: FileRequirementsEditor component in round edit page - Applicant UI: RequirementUploadSlot/List components in submission detail and team pages - Viewer UI: RequirementChecklist with fulfillment status in file-viewer Part B: Super Admin Promotion - Added SUPER_ADMIN to role enums in user create/update/bulkCreate with guards - Member detail page: SUPER_ADMIN dropdown option with AlertDialog confirmation - Invite page: SUPER_ADMIN option visible only to super admins Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
362
src/components/shared/requirement-upload-slot.tsx
Normal file
362
src/components/shared/requirement-upload-slot.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Upload,
|
||||
FileIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { cn, formatFileSize } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function getMimeLabel(mime: string): string {
|
||||
if (mime === 'application/pdf') return 'PDF'
|
||||
if (mime.startsWith('image/')) return 'Images'
|
||||
if (mime.startsWith('video/')) return 'Video'
|
||||
if (mime.includes('wordprocessingml')) return 'Word'
|
||||
if (mime.includes('spreadsheetml')) return 'Excel'
|
||||
if (mime.includes('presentationml')) return 'PowerPoint'
|
||||
if (mime.endsWith('/*')) return mime.replace('/*', '')
|
||||
return mime
|
||||
}
|
||||
|
||||
interface FileRequirement {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
acceptedMimeTypes: string[]
|
||||
maxSizeMB?: number | null
|
||||
isRequired: boolean
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string | Date
|
||||
requirementId?: string | null
|
||||
}
|
||||
|
||||
interface RequirementUploadSlotProps {
|
||||
requirement: FileRequirement
|
||||
existingFile?: UploadedFile | null
|
||||
projectId: string
|
||||
roundId: string
|
||||
onFileChange?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadSlot({
|
||||
requirement,
|
||||
existingFile,
|
||||
projectId,
|
||||
roundId,
|
||||
onFileChange,
|
||||
disabled = false,
|
||||
}: RequirementUploadSlotProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
|
||||
const saveFileMetadata = trpc.applicant.saveFileMetadata.useMutation()
|
||||
const deleteFile = trpc.applicant.deleteFile.useMutation()
|
||||
|
||||
const acceptsMime = useCallback(
|
||||
(mimeType: string) => {
|
||||
if (requirement.acceptedMimeTypes.length === 0) return true
|
||||
return requirement.acceptedMimeTypes.some((pattern) => {
|
||||
if (pattern.endsWith('/*')) {
|
||||
return mimeType.startsWith(pattern.replace('/*', '/'))
|
||||
}
|
||||
return mimeType === pattern
|
||||
})
|
||||
},
|
||||
[requirement.acceptedMimeTypes]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
// Validate mime type
|
||||
if (!acceptsMime(file.type)) {
|
||||
toast.error(`File type ${file.type} is not accepted for this requirement`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (requirement.maxSizeMB && file.size > requirement.maxSizeMB * 1024 * 1024) {
|
||||
toast.error(`File exceeds maximum size of ${requirement.maxSizeMB}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
try {
|
||||
// Get presigned URL
|
||||
const { url, bucket, objectKey, isLate, roundId: uploadRoundId } =
|
||||
await getUploadUrl.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
fileType: 'OTHER',
|
||||
roundId,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
// Upload file with progress tracking
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
setProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
})
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
// Save metadata
|
||||
await saveFileMetadata.mutateAsync({
|
||||
projectId,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
fileType: 'OTHER',
|
||||
bucket,
|
||||
objectKey,
|
||||
roundId: uploadRoundId || roundId,
|
||||
isLate: isLate || false,
|
||||
requirementId: requirement.id,
|
||||
})
|
||||
|
||||
toast.success(`${requirement.name} uploaded successfully`)
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
},
|
||||
[projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!existingFile) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteFile.mutateAsync({ fileId: existingFile.id })
|
||||
toast.success('File deleted')
|
||||
onFileChange?.()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [existingFile, deleteFile, onFileChange])
|
||||
|
||||
const isFulfilled = !!existingFile
|
||||
const statusColor = isFulfilled
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
: requirement.isRequired
|
||||
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950'
|
||||
: 'border-muted'
|
||||
|
||||
// Build accept string for file input
|
||||
const acceptStr =
|
||||
requirement.acceptedMimeTypes.length > 0
|
||||
? requirement.acceptedMimeTypes.join(',')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border p-4 transition-colors', statusColor)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isFulfilled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : requirement.isRequired ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{requirement.name}</span>
|
||||
<Badge
|
||||
variant={requirement.isRequired ? 'destructive' : 'secondary'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{requirement.isRequired ? 'Required' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{requirement.description && (
|
||||
<p className="text-xs text-muted-foreground ml-6 mb-2">
|
||||
{requirement.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1 ml-6 mb-2">
|
||||
{requirement.acceptedMimeTypes.map((mime) => (
|
||||
<Badge key={mime} variant="outline" className="text-xs">
|
||||
{getMimeLabel(mime)}
|
||||
</Badge>
|
||||
))}
|
||||
{requirement.maxSizeMB && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Max {requirement.maxSizeMB}MB
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingFile && (
|
||||
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon className="h-3 w-3" />
|
||||
<span className="truncate">{existingFile.fileName}</span>
|
||||
<span>({formatFileSize(existingFile.size)})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="ml-6 mt-2">
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Uploading... {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{existingFile ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={acceptStr}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RequirementUploadListProps {
|
||||
projectId: string
|
||||
roundId: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({ roundId })
|
||||
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId })
|
||||
|
||||
if (requirements.length === 0) return null
|
||||
|
||||
const handleFileChange = () => {
|
||||
utils.file.listByProject.invalidate({ projectId, roundId })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Required Documents
|
||||
</h3>
|
||||
{requirements.map((req) => {
|
||||
const existing = files.find(
|
||||
(f) => (f as { requirementId?: string | null }).requirementId === req.id
|
||||
)
|
||||
return (
|
||||
<RequirementUploadSlot
|
||||
key={req.id}
|
||||
requirement={req}
|
||||
existingFile={
|
||||
existing
|
||||
? {
|
||||
id: existing.id,
|
||||
fileName: existing.fileName,
|
||||
mimeType: existing.mimeType,
|
||||
size: existing.size,
|
||||
createdAt: existing.createdAt,
|
||||
requirementId: (existing as { requirementId?: string | null }).requirementId,
|
||||
}
|
||||
: null
|
||||
}
|
||||
projectId={projectId}
|
||||
roundId={roundId}
|
||||
onFileChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user