2026-02-14 15:26:42 +01:00
|
|
|
'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,
|
2026-03-03 19:14:41 +01:00
|
|
|
Eye,
|
|
|
|
|
Download,
|
|
|
|
|
FileText,
|
|
|
|
|
Languages,
|
|
|
|
|
Play,
|
|
|
|
|
X,
|
2026-02-14 15:26:42 +01:00
|
|
|
} from 'lucide-react'
|
|
|
|
|
import { cn, formatFileSize } from '@/lib/utils'
|
|
|
|
|
import { toast } from 'sonner'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
function getMimeLabel(mime: string): string {
|
|
|
|
|
if (mime === 'application/pdf') return 'PDF'
|
|
|
|
|
if (mime.startsWith('image/')) return 'Images'
|
2026-03-03 19:14:41 +01:00
|
|
|
if (mime === 'video/mp4') return 'MP4'
|
|
|
|
|
if (mime === 'video/quicktime') return 'MOV'
|
|
|
|
|
if (mime === 'video/webm') return 'WebM'
|
2026-02-14 15:26:42 +01:00
|
|
|
if (mime.startsWith('video/')) return 'Video'
|
2026-03-03 19:14:41 +01:00
|
|
|
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'Word'
|
2026-02-14 15:26:42 +01:00
|
|
|
if (mime.includes('spreadsheetml')) return 'Excel'
|
2026-03-03 19:14:41 +01:00
|
|
|
if (mime.includes('presentationml') || mime === 'application/vnd.ms-powerpoint') return 'PowerPoint'
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
2026-03-03 19:14:41 +01:00
|
|
|
bucket?: string
|
|
|
|
|
objectKey?: string
|
|
|
|
|
pageCount?: number | null
|
|
|
|
|
detectedLang?: string | null
|
|
|
|
|
analyzedAt?: string | Date | null
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RequirementUploadSlotProps {
|
|
|
|
|
requirement: FileRequirement
|
|
|
|
|
existingFile?: UploadedFile | null
|
|
|
|
|
projectId: string
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: string
|
2026-02-14 15:26:42 +01:00
|
|
|
onFileChange?: () => void
|
|
|
|
|
disabled?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
|
|
|
|
|
const { data } = trpc.file.getDownloadUrl.useQuery(
|
|
|
|
|
{ bucket, objectKey, forDownload: false },
|
|
|
|
|
{ staleTime: 10 * 60 * 1000 }
|
|
|
|
|
)
|
|
|
|
|
const href = typeof data === 'string' ? data : data?.url
|
|
|
|
|
return (
|
|
|
|
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
|
|
|
|
|
<a href={href || '#'} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
<Eye className="h-3 w-3" /> View
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
|
|
|
|
const { data } = trpc.file.getDownloadUrl.useQuery(
|
|
|
|
|
{ bucket, objectKey, forDownload: true, fileName },
|
|
|
|
|
{ staleTime: 10 * 60 * 1000 }
|
|
|
|
|
)
|
|
|
|
|
const href = typeof data === 'string' ? data : data?.url
|
|
|
|
|
return (
|
|
|
|
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
|
|
|
|
|
<a href={href || '#'} download={fileName}>
|
|
|
|
|
<Download className="h-3 w-3" /> Download
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
export function RequirementUploadSlot({
|
|
|
|
|
requirement,
|
|
|
|
|
existingFile,
|
|
|
|
|
projectId,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId,
|
2026-02-14 15:26:42 +01:00
|
|
|
onFileChange,
|
|
|
|
|
disabled = false,
|
|
|
|
|
}: RequirementUploadSlotProps) {
|
|
|
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
|
const [progress, setProgress] = useState(0)
|
|
|
|
|
const [deleting, setDeleting] = useState(false)
|
2026-03-03 19:14:41 +01:00
|
|
|
const [showPreview, setShowPreview] = useState(false)
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const { url, bucket, objectKey, isLate, roundId: uploadRoundId } =
|
2026-02-14 15:26:42 +01:00
|
|
|
await getUploadUrl.mutateAsync({
|
|
|
|
|
projectId,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
mimeType: file.type,
|
|
|
|
|
fileType: 'OTHER',
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId,
|
2026-02-14 15:26:42 +01:00
|
|
|
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,
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: uploadRoundId || roundId,
|
2026-02-14 15:26:42 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
},
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
[projectId, roundId, requirement, acceptsMime, getUploadUrl, saveFileMetadata, onFileChange]
|
2026-02-14 15:26:42 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Fetch preview URL only when preview is toggled on
|
|
|
|
|
const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
|
|
|
|
|
{ bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false },
|
|
|
|
|
{ enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 }
|
|
|
|
|
)
|
|
|
|
|
const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url
|
|
|
|
|
|
|
|
|
|
const canPreview = existingFile
|
|
|
|
|
? existingFile.mimeType.startsWith('video/') ||
|
|
|
|
|
existingFile.mimeType === 'application/pdf' ||
|
|
|
|
|
existingFile.mimeType.startsWith('image/') ||
|
|
|
|
|
isOfficeFile(existingFile.mimeType, existingFile.fileName)
|
|
|
|
|
: false
|
|
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
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">
|
2026-03-03 19:14:41 +01:00
|
|
|
{[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => (
|
|
|
|
|
<Badge key={label} variant="outline" className="text-xs">
|
|
|
|
|
{label}
|
2026-02-14 15:26:42 +01:00
|
|
|
</Badge>
|
|
|
|
|
))}
|
|
|
|
|
{requirement.maxSizeMB && (
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
Max {requirement.maxSizeMB}MB
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{existingFile && (
|
2026-03-03 19:14:41 +01:00
|
|
|
<div className="ml-6 space-y-1.5">
|
|
|
|
|
<div className="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>
|
|
|
|
|
{existingFile.pageCount != null && (
|
|
|
|
|
<span className="flex items-center gap-0.5">
|
|
|
|
|
<FileText className="h-3 w-3" />
|
|
|
|
|
{existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{existingFile.detectedLang && existingFile.detectedLang !== 'und' && (
|
|
|
|
|
<span className="flex items-center gap-0.5">
|
|
|
|
|
<Languages className="h-3 w-3" />
|
|
|
|
|
{existingFile.detectedLang.toUpperCase()}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{existingFile.bucket && existingFile.objectKey && (
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
{canPreview && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-6 px-2 text-xs gap-1"
|
|
|
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
|
|
|
>
|
|
|
|
|
{showPreview ? (
|
|
|
|
|
<><X className="h-3 w-3" /> Close Preview</>
|
|
|
|
|
) : (
|
|
|
|
|
<><Play className="h-3 w-3" /> Preview</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<ViewFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} />
|
|
|
|
|
<DownloadFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} fileName={existingFile.fileName} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Inline preview panel */}
|
|
|
|
|
{showPreview && existingFile && (
|
|
|
|
|
<div className="ml-6 mt-2 rounded-lg border bg-muted/50 overflow-hidden">
|
|
|
|
|
{isLoadingPreview ? (
|
|
|
|
|
<div className="flex items-center justify-center py-8">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
) : previewUrl ? (
|
|
|
|
|
<FilePreview
|
|
|
|
|
file={{ mimeType: existingFile.mimeType, fileName: existingFile.fileName }}
|
|
|
|
|
url={previewUrl}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
|
|
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
|
|
|
|
Failed to load preview
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-14 15:26:42 +01:00
|
|
|
</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
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: string
|
2026-02-14 15:26:42 +01:00
|
|
|
disabled?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
export function RequirementUploadList({ projectId, roundId, disabled }: RequirementUploadListProps) {
|
2026-02-14 15:26:42 +01:00
|
|
|
const utils = trpc.useUtils()
|
|
|
|
|
|
|
|
|
|
const { data: requirements = [] } = trpc.file.listRequirements.useQuery({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId,
|
2026-02-14 15:26:42 +01:00
|
|
|
})
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
const { data: files = [] } = trpc.file.listByProject.useQuery({ projectId, roundId })
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
if (requirements.length === 0) return null
|
|
|
|
|
|
|
|
|
|
const handleFileChange = () => {
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
utils.file.listByProject.invalidate({ projectId, roundId })
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-03 19:14:41 +01:00
|
|
|
bucket: (existing as { bucket?: string }).bucket,
|
|
|
|
|
objectKey: (existing as { objectKey?: string }).objectKey,
|
|
|
|
|
pageCount: (existing as { pageCount?: number | null }).pageCount,
|
|
|
|
|
detectedLang: (existing as { detectedLang?: string | null }).detectedLang,
|
|
|
|
|
analyzedAt: (existing as { analyzedAt?: string | null }).analyzedAt,
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
projectId={projectId}
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId={roundId}
|
2026-02-14 15:26:42 +01:00
|
|
|
onFileChange={handleFileChange}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|