'use client' import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Card, CardContent, CardHeader, CardTitle, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { ArrowLeft, CheckCircle2, Upload, Search, X, Loader2, FileUp, AlertCircle, ExternalLink, Trash2, } from 'lucide-react' import { cn, formatFileSize } from '@/lib/utils' import { Pagination } from '@/components/shared/pagination' type UploadState = { progress: number status: 'uploading' | 'complete' | 'error' error?: string } // Key: `${projectId}:${requirementId}` type UploadMap = Record export default function BulkUploadPage() { const [roundId, setRoundId] = useState('') const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all') const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(50) const [uploads, setUploads] = useState({}) // Bulk dialog const [bulkProject, setBulkProject] = useState<{ id: string title: string requirements: Array<{ requirementId: string label: string mimeTypes: string[] required: boolean file: { id: string; fileName: string; bucket: string; objectKey: string } | null }> } | null>(null) const [bulkFiles, setBulkFiles] = useState>({}) const fileInputRefs = useRef>({}) const utils = trpc.useUtils() // Debounce search const searchTimer = useRef>(undefined) const handleSearchChange = useCallback((value: string) => { setSearch(value) clearTimeout(searchTimer.current) searchTimer.current = setTimeout(() => { setDebouncedSearch(value) setPage(1) }, 300) }, []) // Queries const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery() const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery( { roundId, search: debouncedSearch || undefined, status: statusFilter, page, pageSize: perPage, }, { enabled: !!roundId } ) // Collect all files from current data for existence verification const filesToVerify = useMemo(() => { if (!data?.projects) return [] const files: { bucket: string; objectKey: string }[] = [] for (const row of data.projects) { for (const req of row.requirements) { if (req.file?.bucket && req.file?.objectKey) { files.push({ bucket: req.file.bucket, objectKey: req.file.objectKey }) } } } return files }, [data]) // Verify files actually exist in storage const { data: fileExistence } = trpc.file.verifyFilesExist.useQuery( { files: filesToVerify }, { enabled: filesToVerify.length > 0, staleTime: 30_000 } ) // Track which files are missing from storage (objectKey → true means missing) const missingFiles = useMemo(() => { if (!fileExistence) return new Set() const missing = new Set() for (const [key, exists] of Object.entries(fileExistence)) { if (!exists) missing.add(key) } return missing }, [fileExistence]) // Open file in new tab via presigned URL const handleViewFile = useCallback( async (bucket: string, objectKey: string) => { try { const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey }) window.open(url, '_blank') } catch { toast.error('Failed to open file. It may have been deleted from storage.') refetch() } }, [utils, refetch] ) // Delete a file const deleteMutation = trpc.file.delete.useMutation({ onSuccess: () => { toast.success('File removed') refetch() }, onError: (err) => { toast.error(`Failed to remove file: ${err.message}`) }, }) const handleDeleteFile = useCallback( (fileId: string) => { if (confirm('Remove this uploaded file?')) { deleteMutation.mutate({ id: fileId }) } }, [deleteMutation] ) const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation() // Upload a single file for a project requirement const uploadFileForRequirement = useCallback( async ( projectId: string, requirementId: string, file: File, targetRoundId: string ) => { const key = `${projectId}:${requirementId}` setUploads((prev) => ({ ...prev, [key]: { progress: 0, status: 'uploading' }, })) try { const { uploadUrl } = await uploadMutation.mutateAsync({ projectId, fileName: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, roundId: targetRoundId, requirementId, }) // XHR upload with progress await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { const progress = Math.round((event.loaded / event.total) * 100) setUploads((prev) => ({ ...prev, [key]: { progress, status: 'uploading' }, })) } }) 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('Network error'))) xhr.open('PUT', uploadUrl) xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream') xhr.send(file) }) setUploads((prev) => ({ ...prev, [key]: { progress: 100, status: 'complete' }, })) // Refetch data to show updated status refetch() } catch (error) { const msg = error instanceof Error ? error.message : 'Upload failed' setUploads((prev) => ({ ...prev, [key]: { progress: 0, status: 'error', error: msg }, })) toast.error(`Upload failed: ${msg}`) } }, [uploadMutation, refetch] ) // Handle single cell file pick const handleCellUpload = useCallback( (projectId: string, requirementId: string, mimeTypes: string[]) => { const input = document.createElement('input') input.type = 'file' if (mimeTypes.length > 0) { input.accept = mimeTypes.join(',') } input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0] if (file && roundId) { uploadFileForRequirement(projectId, requirementId, file, roundId) } } input.click() }, [roundId, uploadFileForRequirement] ) // Handle bulk row upload const handleBulkUploadAll = useCallback(async () => { if (!bulkProject || !roundId) return const entries = Object.entries(bulkFiles).filter( ([, file]) => file !== null ) as Array<[string, File]> if (entries.length === 0) { toast.error('No files selected') return } // Upload all in parallel await Promise.allSettled( entries.map(([reqId, file]) => uploadFileForRequirement(bulkProject.id, reqId, file, roundId) ) ) setBulkProject(null) setBulkFiles({}) toast.success('Bulk upload complete') }, [bulkProject, bulkFiles, roundId, uploadFileForRequirement]) const progressPercent = data && data.totalProjects > 0 ? Math.round((data.completeCount / data.totalProjects) * 100) : 0 return (
{/* Header */}

Bulk Document Upload

Upload required documents for multiple projects at once

{/* Round Selector */} Round {roundsLoading ? ( ) : !rounds || rounds.length === 0 ? (
No rounds have file requirements configured. Add file requirements to a round first.
) : ( )}
{/* Content (only if round selected) */} {roundId && data && ( <> {/* Progress Summary */}

{data.completeCount} / {data.totalProjects} projects have complete documents

{progressPercent}%
{/* Filters */}
handleSearchChange(e.target.value)} placeholder="Search by project name or team..." className="pl-10 pr-10" /> {search && ( )}
{/* Table */} {isLoading ? (
{[...Array(5)].map((_, i) => ( ))}
) : data.projects.length === 0 ? (

No projects found

{debouncedSearch ? 'Try adjusting your search' : 'No projects in this program'}

) : ( <>
Project Applicant {data.requirements.map((req) => (
{req.label} {req.required && ( * )}
{req.mimeTypes.join(', ') || 'Any'}
))} Actions
{data.projects.map((row) => { const missingRequired = row.requirements.filter( (r) => r.required && (!r.file || (r.file?.objectKey && missingFiles.has(r.file.objectKey))) ) return ( {row.project.title} {row.project.submittedBy?.name || row.project.submittedBy?.email || row.project.teamName || '-'} {row.requirements.map((req) => { const uploadKey = `${row.project.id}:${req.requirementId}` const uploadState = uploads[uploadKey] return ( {uploadState?.status === 'uploading' ? (
{uploadState.progress}%
) : uploadState?.status === 'error' ? (
) : req.file && req.file.objectKey && missingFiles.has(req.file.objectKey) ? (
Missing
) : req.file || uploadState?.status === 'complete' ? (
{req.file && ( )}
{req.file?.bucket && req.file?.objectKey ? ( ) : ( {req.file?.fileName ?? 'Uploaded'} )}
) : ( )}
) })} {missingRequired.length > 0 && ( )} {row.isComplete && ( Complete )}
) })}
{ setPerPage(pp) setPage(1) }} /> )} )} {/* Bulk Upload Dialog */} { if (!open) { setBulkProject(null) setBulkFiles({}) } }} > Upload Files for {bulkProject?.title} Select files for each missing requirement, then upload all at once. {bulkProject && (
{bulkProject.requirements .filter((r) => !r.file) .map((req) => { const selectedFile = bulkFiles[req.requirementId] const uploadKey = `${bulkProject.id}:${req.requirementId}` const uploadState = uploads[uploadKey] return (

{req.label} {req.required && ( * )}

{req.mimeTypes.join(', ') || 'Any file type'}

{selectedFile && !uploadState && (
{selectedFile.name} {formatFileSize(selectedFile.size)}
)} {uploadState?.status === 'uploading' && (
{uploadState.progress}%
)} {uploadState?.status === 'error' && (

{uploadState.error}

)}
{uploadState?.status === 'complete' ? ( ) : uploadState?.status === 'uploading' ? ( ) : ( <> { fileInputRefs.current[req.requirementId] = el }} type="file" className="hidden" accept={ req.mimeTypes.length > 0 ? req.mimeTypes.join(',') : undefined } onChange={(e) => { const file = e.target.files?.[0] if (file) { setBulkFiles((prev) => ({ ...prev, [req.requirementId]: file, })) } e.target.value = '' }} /> )}
) })}
)}
) }