'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(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) => { 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((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 (
{isFulfilled ? ( ) : requirement.isRequired ? ( ) : ( )} {requirement.name} {requirement.isRequired ? 'Required' : 'Optional'}
{requirement.description && (

{requirement.description}

)}
{requirement.acceptedMimeTypes.map((mime) => ( {getMimeLabel(mime)} ))} {requirement.maxSizeMB && ( Max {requirement.maxSizeMB}MB )}
{existingFile && (
{existingFile.fileName} ({formatFileSize(existingFile.size)})
)} {uploading && (

Uploading... {progress}%

)}
{!disabled && (
{existingFile ? ( <> ) : ( )}
)}
) } 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 (

Required Documents

{requirements.map((req) => { const existing = files.find( (f) => (f as { requirementId?: string | null }).requirementId === req.id ) return ( ) })}
) }