'use client' import { useState, useCallback, useRef } 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, } 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 [windowId, setWindowId] = 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 } | null }> } | null>(null) const [bulkFiles, setBulkFiles] = useState>({}) const fileInputRefs = useRef>({}) // 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: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery() const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery( { submissionWindowId: windowId, search: debouncedSearch || undefined, status: statusFilter, page, pageSize: perPage, }, { enabled: !!windowId } ) const uploadMutation = trpc.file.adminUploadForRequirement.useMutation() // Upload a single file for a project requirement const uploadFileForRequirement = useCallback( async ( projectId: string, requirementId: string, file: File, submissionWindowId: 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, submissionWindowId, submissionFileRequirementId: 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 && windowId) { uploadFileForRequirement(projectId, requirementId, file, windowId) } } input.click() }, [windowId, uploadFileForRequirement] ) // Handle bulk row upload const handleBulkUploadAll = useCallback(async () => { if (!bulkProject || !windowId) 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, windowId) ) ) setBulkProject(null) setBulkFiles({}) toast.success('Bulk upload complete') }, [bulkProject, bulkFiles, windowId, 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

{/* Window Selector */} Submission Window {windowsLoading ? ( ) : ( )} {/* Content (only if window selected) */} {windowId && 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 ) 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 || uploadState?.status === 'complete' ? (
{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 = '' }} /> )}
) })}
)}
) }