Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s

- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION
- Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards)
- Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review)
- Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone
- Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields
- Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish
- Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors
- Bulk upload page for admin project imports
- File router enhanced with admin upload and submission window procedures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:16:55 +01:00
parent fbb194067d
commit 4c0efb232c
23 changed files with 5745 additions and 891 deletions

View File

@@ -87,20 +87,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch file requirements from the competition's intake round
// Note: This procedure may need to be updated or removed depending on new system
// const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery(
// { projectId },
// { enabled: !!project }
// )
const requirementsData = null // Placeholder until procedure is updated
// Fetch available rounds for upload selector (if project has a programId)
const { data: programData } = trpc.program.get.useQuery(
{ id: project?.programId || '' },
// Fetch competitions for this project's program to get rounds
const { data: competitions } = trpc.competition.list.useQuery(
{ programId: project?.programId || '' },
{ enabled: !!project?.programId }
)
const availableRounds = (programData?.stages as Array<{ id: string; name: string }>) || []
// Get first competition ID to fetch full details with rounds
const competitionId = competitions?.[0]?.id
// Fetch full competition details including rounds
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId || '' },
{ enabled: !!competitionId }
)
// Extract all rounds from the competition
const competitionRounds = competition?.rounds || []
// Fetch requirements for each round
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
trpc.file.listRequirements.useQuery({ roundId: round.id })
)
// Combine requirements from all rounds
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
const utils = trpc.useUtils()
@@ -157,7 +168,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
href={`/admin/programs/${project.programId}`}
className="hover:underline"
>
{programData?.name ?? 'Program'}
Program
</Link>
) : (
<span>No program</span>
@@ -526,84 +537,114 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
Files
</CardTitle>
<CardDescription>
Project documents and materials
Project documents and materials organized by competition round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Required Documents from Competition Intake Round */}
{requirementsData && (requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements?.length > 0 && (
<CardContent className="space-y-6">
{/* Requirements organized by round */}
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
<>
<div>
<p className="text-sm font-semibold mb-3">Required Documents</p>
<div className="grid gap-2">
{(requirementsData as { requirements: Array<{ id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }> }).requirements.map((req: { id?: string; name: string; isRequired?: boolean; description?: string; maxSizeMB?: number; fulfilled: boolean; fulfilledFile?: { fileName: string } }, idx: number) => {
const isFulfilled = req.fulfilled
return (
<div
key={req.id ?? `req-${idx}`}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="secondary" className="text-xs shrink-0">
Required
</Badge>
{competitionRounds.map((round: { id: string; name: string }) => {
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
if (roundRequirements.length === 0) return null
return (
<div key={round.id} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{round.name}</h3>
<Badge variant="outline" className="text-xs">
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid gap-2">
{roundRequirements.map((req: any) => {
// Find file that fulfills this requirement
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
const isFulfilled = !!fulfilledFile
return (
<div
key={req.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="destructive" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
{req.description && (
<p className="text-xs text-muted-foreground truncate">
{req.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{req.acceptedMimeTypes.length > 0 && (
<span>
{req.acceptedMimeTypes.map((mime: string) => {
if (mime === 'application/pdf') return 'PDF'
if (mime === 'image/*') return 'Images'
if (mime === 'video/*') return 'Video'
if (mime.includes('wordprocessing')) return 'Word'
if (mime.includes('spreadsheet')) return 'Excel'
if (mime.includes('presentation')) return 'PowerPoint'
return mime.split('/')[1] || mime
}).join(', ')}
</span>
)}
{req.maxSizeMB && (
<span className="shrink-0"> Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
{fulfilledFile.fileName}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{req.description && (
<span className="truncate">{req.description}</span>
)}
{req.maxSizeMB && (
<span className="shrink-0">Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && req.fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{req.fulfilledFile.fileName}
</p>
{!isFulfilled && (
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
Missing
</span>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Missing
</span>
)}
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
})}
<Separator />
</>
)}
) : null}
{/* Additional Documents Upload */}
{/* General file upload section */}
<div>
<p className="text-sm font-semibold mb-3">
{requirementsData && (requirementsData as { requirements: unknown[] }).requirements?.length > 0
? 'Additional Documents'
: 'Upload New Files'}
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
</p>
<p className="text-xs text-muted-foreground mb-3">
Upload files not tied to specific requirements
</p>
<FileUpload
projectId={projectId}
availableRounds={availableRounds?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
// utils.file.getProjectRequirements.invalidate({ projectId })
}}
/>
</div>
@@ -613,7 +654,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<>
<Separator />
<div>
<p className="text-sm font-semibold mb-3">All Files</p>
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
<FileViewer
projectId={projectId}
files={files.map((f) => ({

View File

@@ -0,0 +1,689 @@
'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<string, UploadState>
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<UploadMap>({})
// 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<Record<string, File | null>>({})
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
// Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout>>(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<void>((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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/projects">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
<p className="text-muted-foreground">
Upload required documents for multiple projects at once
</p>
</div>
</div>
{/* Window Selector */}
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Window</CardTitle>
</CardHeader>
<CardContent>
{windowsLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={windowId}
onValueChange={(v) => {
setWindowId(v)
setPage(1)
setUploads({})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a submission window..." />
</SelectTrigger>
<SelectContent>
{windows?.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.competition.program.name} {w.competition.program.year} &mdash; {w.name}{' '}
({w.fileRequirements.length} requirement
{w.fileRequirements.length !== 1 ? 's' : ''})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</CardContent>
</Card>
{/* Content (only if window selected) */}
{windowId && data && (
<>
{/* Progress Summary */}
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">
{data.completeCount} / {data.totalProjects} projects have complete documents
</p>
<Badge variant={progressPercent === 100 ? 'success' : 'secondary'}>
{progressPercent}%
</Badge>
</div>
<Progress value={progressPercent} className="h-2" />
</CardContent>
</Card>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by project name or team..."
className="pl-10 pr-10"
/>
{search && (
<button
type="button"
onClick={() => {
setSearch('')
setDebouncedSearch('')
setPage(1)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as 'all' | 'missing' | 'complete')
setPage(1)
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="missing">Missing files</SelectItem>
<SelectItem value="complete">Complete</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
) : data.projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileUp className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
{debouncedSearch ? 'Try adjusting your search' : 'No projects in this program'}
</p>
</CardContent>
</Card>
) : (
<>
<Card>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Project</TableHead>
<TableHead>Applicant</TableHead>
{data.requirements.map((req) => (
<TableHead key={req.id} className="min-w-[160px] text-center">
<div>
{req.label}
{req.required && (
<span className="text-destructive ml-0.5">*</span>
)}
</div>
<div className="text-[10px] font-normal text-muted-foreground">
{req.mimeTypes.join(', ') || 'Any'}
</div>
</TableHead>
))}
<TableHead className="text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((row) => {
const missingRequired = row.requirements.filter(
(r) => r.required && !r.file
)
return (
<TableRow
key={row.project.id}
className={row.isComplete ? 'bg-green-50/50 dark:bg-green-950/10' : ''}
>
<TableCell>
<Link
href={`/admin/projects/${row.project.id}`}
className="font-medium hover:underline"
>
{row.project.title}
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.project.submittedBy?.name ||
row.project.submittedBy?.email ||
row.project.teamName ||
'-'}
</TableCell>
{row.requirements.map((req) => {
const uploadKey = `${row.project.id}:${req.requirementId}`
const uploadState = uploads[uploadKey]
return (
<TableCell key={req.requirementId} className="text-center">
{uploadState?.status === 'uploading' ? (
<div className="flex flex-col items-center gap-1">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<Progress
value={uploadState.progress}
className="h-1 w-16"
/>
<span className="text-[10px] text-muted-foreground">
{uploadState.progress}%
</span>
</div>
) : uploadState?.status === 'error' ? (
<div className="flex flex-col items-center gap-1">
<AlertCircle className="h-4 w-4 text-destructive" />
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
Retry
</Button>
</div>
) : req.file || uploadState?.status === 'complete' ? (
<div className="flex flex-col items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{req.file?.fileName ?? 'Uploaded'}
</span>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
<Upload className="mr-1 h-3 w-3" />
Upload
</Button>
)}
</TableCell>
)
})}
<TableCell className="text-center">
{missingRequired.length > 0 && (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => {
setBulkProject({
id: row.project.id,
title: row.project.title,
requirements: row.requirements,
})
setBulkFiles({})
}}
>
<FileUp className="mr-1 h-3 w-3" />
Upload All ({missingRequired.length})
</Button>
)}
{row.isComplete && (
<Badge variant="success" className="text-xs">
Complete
</Badge>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</Card>
<Pagination
page={data.page}
totalPages={data.totalPages}
total={data.total}
perPage={perPage}
onPageChange={setPage}
onPerPageChange={(pp) => {
setPerPage(pp)
setPage(1)
}}
/>
</>
)}
</>
)}
{/* Bulk Upload Dialog */}
<Dialog
open={!!bulkProject}
onOpenChange={(open) => {
if (!open) {
setBulkProject(null)
setBulkFiles({})
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Upload Files for {bulkProject?.title}</DialogTitle>
<DialogDescription>
Select files for each missing requirement, then upload all at once.
</DialogDescription>
</DialogHeader>
{bulkProject && (
<div className="space-y-4 py-2">
{bulkProject.requirements
.filter((r) => !r.file)
.map((req) => {
const selectedFile = bulkFiles[req.requirementId]
const uploadKey = `${bulkProject.id}:${req.requirementId}`
const uploadState = uploads[uploadKey]
return (
<div
key={req.requirementId}
className={cn(
'flex items-center gap-3 rounded-lg border p-3',
uploadState?.status === 'complete' &&
'border-green-500/50 bg-green-500/5',
uploadState?.status === 'error' &&
'border-destructive/50 bg-destructive/5'
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{req.label}
{req.required && (
<span className="text-destructive ml-0.5">*</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{req.mimeTypes.join(', ') || 'Any file type'}
</p>
{selectedFile && !uploadState && (
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{selectedFile.name}
</Badge>
<span className="text-[10px] text-muted-foreground">
{formatFileSize(selectedFile.size)}
</span>
<button
type="button"
onClick={() =>
setBulkFiles((prev) => ({
...prev,
[req.requirementId]: null,
}))
}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{uploadState?.status === 'uploading' && (
<div className="flex items-center gap-2 mt-1">
<Progress
value={uploadState.progress}
className="h-1 flex-1"
/>
<span className="text-xs text-muted-foreground">
{uploadState.progress}%
</span>
</div>
)}
{uploadState?.status === 'error' && (
<p className="text-xs text-destructive mt-1">
{uploadState.error}
</p>
)}
</div>
<div className="shrink-0">
{uploadState?.status === 'complete' ? (
<CheckCircle2 className="h-5 w-5 text-green-600" />
) : uploadState?.status === 'uploading' ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : (
<>
<input
ref={(el) => {
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 = ''
}}
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() =>
fileInputRefs.current[req.requirementId]?.click()
}
>
{selectedFile ? 'Change' : 'Select'}
</Button>
</>
)}
</div>
</div>
)
})}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
onClick={() => {
setBulkProject(null)
setBulkFiles({})
}}
>
Cancel
</Button>
<Button
onClick={handleBulkUploadAll}
disabled={
Object.values(bulkFiles).filter(Boolean).length === 0 ||
Object.values(uploads).some((u) => u.status === 'uploading')
}
>
{Object.values(uploads).some((u) => u.status === 'uploading') ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload {Object.values(bulkFiles).filter(Boolean).length} File
{Object.values(bulkFiles).filter(Boolean).length !== 1 ? 's' : ''}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -622,6 +622,12 @@ export default function ProjectsPage() {
<Bot className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/bulk-upload">
<FileUp className="mr-2 h-4 w-4" />
Bulk Upload
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />