All checks were successful
Build and Push Docker Image / build (push) Successful in 10m28s
Replace "View" (opens new tab) with inline collapsible preview panel. Supports PDF, video, image, and Office documents using existing FilePreview component. Download button triggers native download. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
|
|
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
|
|
import {
|
|
FileText,
|
|
Upload,
|
|
AlertTriangle,
|
|
Clock,
|
|
Video,
|
|
File,
|
|
Download,
|
|
Eye,
|
|
X,
|
|
Loader2,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
const fileTypeIcons: Record<string, typeof FileText> = {
|
|
EXEC_SUMMARY: FileText,
|
|
BUSINESS_PLAN: FileText,
|
|
PRESENTATION: FileText,
|
|
VIDEO_PITCH: Video,
|
|
VIDEO: Video,
|
|
OTHER: File,
|
|
SUPPORTING_DOC: File,
|
|
}
|
|
|
|
const fileTypeLabels: Record<string, string> = {
|
|
EXEC_SUMMARY: 'Executive Summary',
|
|
BUSINESS_PLAN: 'Business Plan',
|
|
PRESENTATION: 'Presentation',
|
|
VIDEO_PITCH: 'Video Pitch',
|
|
VIDEO: 'Video',
|
|
OTHER: 'Other Document',
|
|
SUPPORTING_DOC: 'Supporting Document',
|
|
}
|
|
|
|
function FileRow({ file }: { file: { id: string; fileName: string; fileType: string; createdAt: string | Date; isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string } }) {
|
|
const [showPreview, setShowPreview] = useState(false)
|
|
const Icon = fileTypeIcons[file.fileType] || File
|
|
const mimeType = file.mimeType || ''
|
|
|
|
const canPreview =
|
|
mimeType.startsWith('video/') ||
|
|
mimeType === 'application/pdf' ||
|
|
mimeType.startsWith('image/') ||
|
|
isOfficeFile(mimeType, file.fileName)
|
|
|
|
const { data: previewData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
|
|
{ bucket: file.bucket!, objectKey: file.objectKey!, purpose: 'preview' as const },
|
|
{ enabled: showPreview && !!file.bucket && !!file.objectKey, staleTime: 10 * 60 * 1000 }
|
|
)
|
|
|
|
return (
|
|
<div className="rounded-lg border overflow-hidden">
|
|
<div className="flex items-center justify-between p-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-sm truncate">{file.fileName}</p>
|
|
{file.isLate && (
|
|
<Badge variant="warning" className="text-xs gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
Late
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{fileTypeLabels[file.fileType] || file.fileType}
|
|
{' - '}
|
|
{new Date(file.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{file.bucket && file.objectKey && (
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{canPreview && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs gap-1"
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
>
|
|
{showPreview ? (
|
|
<><X className="h-3 w-3" /> Close</>
|
|
) : (
|
|
<><Eye className="h-3 w-3" /> View</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
<DownloadButton bucket={file.bucket} objectKey={file.objectKey} fileName={file.fileName} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showPreview && (
|
|
<div className="border-t bg-muted/50">
|
|
{isLoadingPreview ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : previewData?.url ? (
|
|
<FilePreview file={{ mimeType, fileName: file.fileName }} url={previewData.url} />
|
|
) : (
|
|
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
|
Failed to load preview
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DownloadButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
|
|
const [downloading, setDownloading] = useState(false)
|
|
|
|
const { refetch } = trpc.file.getDownloadUrl.useQuery(
|
|
{ bucket, objectKey, forDownload: true, fileName, purpose: 'download' as const },
|
|
{ enabled: false }
|
|
)
|
|
|
|
const handleDownload = async () => {
|
|
setDownloading(true)
|
|
try {
|
|
const result = await refetch()
|
|
if (result.data?.url) {
|
|
window.location.href = result.data.url
|
|
}
|
|
} catch {
|
|
toast.error('Failed to download file')
|
|
} finally {
|
|
setTimeout(() => setDownloading(false), 1000)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleDownload} disabled={downloading}>
|
|
{downloading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
|
Download
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export default function ApplicantDocumentsPage() {
|
|
const { status: sessionStatus } = useSession()
|
|
const isAuthenticated = sessionStatus === 'authenticated'
|
|
|
|
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
|
enabled: isAuthenticated,
|
|
})
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</div>
|
|
<Skeleton className="h-64 w-full" />
|
|
<Skeleton className="h-48 w-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data?.project) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Documents</h1>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
|
<h2 className="text-xl font-semibold mb-2">No Project</h2>
|
|
<p className="text-muted-foreground text-center">
|
|
Submit a project first to upload documents.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { project, openRounds, isRejected } = data
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
|
<Upload className="h-6 w-6" />
|
|
Documents
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
Upload and manage documents for your project: {project.title}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Rejected banner */}
|
|
{isRejected && (
|
|
<Card className="border-destructive/50 bg-destructive/5">
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
|
|
<p className="text-sm text-destructive">
|
|
Your project was not selected to advance. Documents are view-only.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Per-round upload sections */}
|
|
{!isRejected && openRounds.length > 0 && (
|
|
<div className="space-y-6">
|
|
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
|
const now = new Date()
|
|
const hasDeadline = !!round.windowCloseAt
|
|
const deadlinePassed = hasDeadline && now > new Date(round.windowCloseAt!)
|
|
const isLate = deadlinePassed
|
|
|
|
return (
|
|
<Card key={round.id}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">{round.name}</CardTitle>
|
|
<CardDescription>
|
|
Upload documents for this round
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isLate && (
|
|
<Badge variant="warning" className="gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
Late submission
|
|
</Badge>
|
|
)}
|
|
{hasDeadline && !deadlinePassed && (
|
|
<Badge variant="outline" className="gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
Due {new Date(round.windowCloseAt!).toLocaleDateString()}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RequirementUploadList
|
|
projectId={project.id}
|
|
roundId={round.id}
|
|
disabled={false}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploaded files list */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Uploaded Documents</CardTitle>
|
|
<CardDescription>
|
|
All files associated with your project
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.files.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-4">
|
|
No documents uploaded yet
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{project.files.map((file) => {
|
|
const fileRecord = file as typeof file & { isLate?: boolean; bucket?: string; objectKey?: string; mimeType?: string }
|
|
return (
|
|
<FileRow key={file.id} file={fileRecord} />
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* No open rounds message */}
|
|
{openRounds.length === 0 && project.files.length === 0 && (
|
|
<Card className="bg-muted/50">
|
|
<CardContent className="p-6 text-center">
|
|
<Clock className="h-10 w-10 mx-auto text-muted-foreground/50 mb-3" />
|
|
<p className="text-muted-foreground">
|
|
No rounds are currently open for document submissions.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|