From 0f6473c99983d5edb5ef98b247fd701b8bce8a15 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Feb 2026 14:03:38 +0100 Subject: [PATCH] Jury inline doc preview, download fix, category tags, admin eval reset - Replace MultiWindowDocViewer with FileViewer for inline previews (PDF/image/video/Office) - Fix cross-origin download using fetch+blob instead of - Show Startup/Business Concept badge on jury project detail + evaluate pages - Add admin resetEvaluation procedure with audit logging - Add dropdown menu on admin assignment rows with Reset Evaluation + Delete - Make file action buttons responsive on mobile (separate row below file info) Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 63 ++++- .../projects/[projectId]/evaluate/page.tsx | 17 +- .../[roundId]/projects/[projectId]/page.tsx | 15 +- .../jury/multi-window-doc-viewer.tsx | 244 ++++++------------ src/components/shared/file-viewer.tsx | 166 +++++++----- src/server/routers/evaluation.ts | 61 +++++ 6 files changed, 318 insertions(+), 248 deletions(-) diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index f196e6f..05be48c 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -81,6 +81,7 @@ import { Check, ChevronsUpDown, Search, + MoreHorizontal, } from 'lucide-react' import { Command, @@ -2331,6 +2332,14 @@ function IndividualAssignmentsTable({ onError: (err) => toast.error(err.message), }) + const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({ + onSuccess: () => { + utils.assignment.listByStage.invalidate({ roundId }) + toast.success('Evaluation reset — juror can now start over') + }, + onError: (err) => toast.error(err.message), + }) + const createMutation = trpc.assignment.create.useMutation({ onSuccess: () => { utils.assignment.listByStage.invalidate({ roundId }) @@ -2457,17 +2466,17 @@ function IndividualAssignmentsTable({

) : (
-
+
Juror Project Status - + Actions
{assignments.map((a: any, idx: number) => (
@@ -2479,22 +2488,50 @@ function IndividualAssignmentsTable({ 'text-[10px] justify-center', a.evaluation?.status === 'SUBMITTED' ? 'bg-emerald-50 text-emerald-700 border-emerald-200' - : a.evaluation?.status === 'IN_PROGRESS' + : a.evaluation?.status === 'DRAFT' ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-gray-50 text-gray-600 border-gray-200', )} > {a.evaluation?.status || 'PENDING'} - + + + + + + {a.evaluation && ( + <> + { + if (confirm(`Reset evaluation by ${a.user?.name || a.user?.email} for "${a.project?.title}"? This will erase all scores and feedback so they can start over.`)) { + resetEvalMutation.mutate({ assignmentId: a.id }) + } + }} + disabled={resetEvalMutation.isPending} + > + + Reset Evaluation + + + + )} + { + if (confirm(`Remove assignment for ${a.user?.name || a.user?.email} on "${a.project?.title}"?`)) { + deleteMutation.mutate({ id: a.id }) + } + }} + disabled={deleteMutation.isPending} + > + + Delete Assignment + + +
))}
diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx index 1927fb9..0832e02 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx @@ -14,6 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { cn } from '@/lib/utils' import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer' +import { Badge } from '@/components/ui/badge' import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2 } from 'lucide-react' import { toast } from 'sonner' import type { EvaluationConfig } from '@/types/competition-configs' @@ -433,7 +434,21 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {

Evaluate Project

-

{project.title}

+
+

{project.title}

+ {project.competitionCategory && ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + )} +
diff --git a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx index 9f1abc7..4ffcd94 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/page.tsx @@ -95,15 +95,24 @@ export default function JuryProjectDetailPage() { {/* Project metadata */}
+ {project.competitionCategory && ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + )} {project.country && ( {project.country} )} - {project.competitionCategory && ( - {project.competitionCategory} - )}
{/* Project tags */} diff --git a/src/components/jury/multi-window-doc-viewer.tsx b/src/components/jury/multi-window-doc-viewer.tsx index 68f7e91..aac94df 100644 --- a/src/components/jury/multi-window-doc-viewer.tsx +++ b/src/components/jury/multi-window-doc-viewer.tsx @@ -1,137 +1,16 @@ 'use client' -import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' -import { FileText, Download, ExternalLink, Loader2 } from 'lucide-react' -import { toast } from 'sonner' +import { FileText } from 'lucide-react' +import { FileViewer } from '@/components/shared/file-viewer' interface MultiWindowDocViewerProps { roundId: string projectId: string } -function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] -} - -function getFileIcon(mimeType: string) { - if (mimeType.startsWith('image/')) return '🖼️' - if (mimeType.startsWith('video/')) return '🎥' - if (mimeType.includes('pdf')) return '📄' - if (mimeType.includes('word') || mimeType.includes('document')) return '📝' - if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊' - if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📊' - return '📎' -} - -function canPreviewInBrowser(mimeType: string): boolean { - return ( - mimeType === 'application/pdf' || - mimeType.startsWith('image/') || - mimeType.startsWith('video/') || - mimeType.startsWith('text/') - ) -} - -function FileCard({ file }: { file: { id: string; fileName: string; mimeType: string; size: number; bucket: string; objectKey: string } }) { - const [loadingAction, setLoadingAction] = useState<'download' | 'preview' | null>(null) - - const downloadUrlQuery = trpc.file.getDownloadUrl.useQuery( - { bucket: file.bucket, objectKey: file.objectKey }, - { enabled: false } // manual trigger - ) - - const handleAction = async (action: 'download' | 'preview') => { - setLoadingAction(action) - try { - const result = await downloadUrlQuery.refetch() - if (result.data?.url) { - if (action === 'preview' && canPreviewInBrowser(file.mimeType)) { - window.open(result.data.url, '_blank') - } else { - // Download: create temp link and click - const a = document.createElement('a') - a.href = result.data.url - a.download = file.fileName - a.target = '_blank' - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - } - } - } catch { - toast.error('Failed to get file URL') - } finally { - setLoadingAction(null) - } - } - - return ( - - -
-
{getFileIcon(file.mimeType || '')}
-
-

- {file.fileName} -

-
- - {file.mimeType?.split('/')[1]?.toUpperCase() || 'FILE'} - - {file.size > 0 && ( - - {formatFileSize(file.size)} - - )} -
-
- - {canPreviewInBrowser(file.mimeType) && ( - - )} -
-
-
-
-
- ) -} - export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewerProps) { const { data: files, isLoading } = trpc.file.listByProject.useQuery( { projectId }, @@ -166,53 +45,82 @@ export function MultiWindowDocViewer({ roundId, projectId }: MultiWindowDocViewe ) } - // Group files by round name or "General" - const grouped: Record = {} + // Group files by round name for the grouped view + const groupMap: Record = {} + for (const file of files) { - const groupName = file.requirement?.round?.name ?? 'General' - if (!grouped[groupName]) grouped[groupName] = [] - grouped[groupName].push(file) + const roundName = file.requirement?.round?.name ?? 'General' + const rId = file.requirement?.round?.id ?? null + const sortOrder = file.requirement?.round?.sortOrder ?? 999 + if (!groupMap[roundName]) { + groupMap[roundName] = { roundId: rId, roundName, sortOrder, files: [] } + } + groupMap[roundName].files.push(file) } - const groupNames = Object.keys(grouped) + const groupedFiles = Object.values(groupMap) - return ( - - - Documents - - {files.length} file{files.length !== 1 ? 's' : ''} submitted - - - - {groupNames.length === 1 ? ( - // Single group — no need for headers -
- {grouped[groupNames[0]].map((file) => ( - - ))} -
- ) : ( - // Multiple groups — show headers -
- {groupNames.map((groupName) => ( -
-

- {groupName} - - {grouped[groupName].length} - -

-
- {grouped[groupName].map((file) => ( - - ))} -
-
- ))} -
- )} -
-
- ) + // If only one group, use flat view + if (groupedFiles.length === 1) { + const mappedFiles = files.map((f) => ({ + id: f.id, + fileType: (f.fileType ?? 'OTHER') as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC', + fileName: f.fileName, + mimeType: f.mimeType, + size: f.size, + bucket: f.bucket, + objectKey: f.objectKey, + version: f.version ?? undefined, + requirementId: f.requirementId, + requirement: f.requirement ? { + id: f.requirement.id, + name: f.requirement.name, + description: f.requirement.description, + isRequired: f.requirement.isRequired, + } : undefined, + pageCount: (f as any).pageCount ?? undefined, + textPreview: (f as any).textPreview ?? undefined, + detectedLang: (f as any).detectedLang ?? undefined, + langConfidence: (f as any).langConfidence ?? undefined, + analyzedAt: (f as any).analyzedAt ?? undefined, + })) + + return + } + + // Multiple groups — use grouped view + const mappedGroups = groupedFiles.map((g) => ({ + roundId: g.roundId, + roundName: g.roundName, + sortOrder: g.sortOrder, + files: g.files.map((f) => ({ + id: f.id, + fileType: (f.fileType ?? 'OTHER') as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC', + fileName: f.fileName, + mimeType: f.mimeType, + size: f.size, + bucket: f.bucket, + objectKey: f.objectKey, + version: f.version ?? undefined, + requirementId: f.requirementId, + requirement: f.requirement ? { + id: f.requirement.id, + name: f.requirement.name, + description: f.requirement.description, + isRequired: f.requirement.isRequired, + } : undefined, + pageCount: (f as any).pageCount ?? undefined, + textPreview: (f as any).textPreview ?? undefined, + detectedLang: (f as any).detectedLang ?? undefined, + langConfidence: (f as any).langConfidence ?? undefined, + analyzedAt: (f as any).analyzedAt ?? undefined, + })), + })) + + return } diff --git a/src/components/shared/file-viewer.tsx b/src/components/shared/file-viewer.tsx index 2935017..0303901 100644 --- a/src/components/shared/file-viewer.tsx +++ b/src/components/shared/file-viewer.tsx @@ -252,75 +252,104 @@ function FileItem({ file }: { file: ProjectFile }) { return (
-
-
- -
+
+
+
+ +
-
- {file.requirement && ( -

- {file.requirement.name} -

- )} -
-

{file.fileName}

+
+ {file.requirement && ( +

+ {file.requirement.name} +

+ )} +
+

{file.fileName}

+ {file.version != null && file.version > 1 && ( + + v{file.version} + + )} +
+
+ + {getFileTypeLabel(file.fileType)} + + {file.isLate && ( + + Late + + )} + {formatFileSize(file.size)} + {file.pageCount != null && ( + + + {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} + + )} + {file.detectedLang && file.detectedLang !== 'und' && ( + = 0.8, + 'border-amber-300 text-amber-700 bg-amber-50': file.langConfidence != null && file.langConfidence >= 0.4 && file.langConfidence < 0.8, + 'border-red-300 text-red-700 bg-red-50': file.langConfidence != null && file.langConfidence < 0.4, + })} + title={`Language: ${file.detectedLang} (${Math.round((file.langConfidence ?? 0) * 100)}% confidence)`} + > + {file.detectedLang.toUpperCase()} + + )} +
+
+ + {/* Desktop action buttons — hidden on mobile */} +
{file.version != null && file.version > 1 && ( - - v{file.version} - + )} -
-
- - {getFileTypeLabel(file.fileType)} - - {file.isLate && ( - - Late - - )} - {formatFileSize(file.size)} - {file.pageCount != null && ( - - - {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} - - )} - {file.detectedLang && file.detectedLang !== 'und' && ( - = 0.8, - 'border-amber-300 text-amber-700 bg-amber-50': file.langConfidence != null && file.langConfidence >= 0.4 && file.langConfidence < 0.8, - 'border-red-300 text-red-700 bg-red-50': file.langConfidence != null && file.langConfidence < 0.4, - })} - title={`Language: ${file.detectedLang} (${Math.round((file.langConfidence ?? 0) * 100)}% confidence)`} + size="sm" + onClick={() => setShowPreview(!showPreview)} > - {file.detectedLang.toUpperCase()} - + {showPreview ? ( + <> + + Close + + ) : ( + <> + + Preview + + )} + )} + +
-
- {file.version != null && file.version > 1 && ( - - )} + {/* Mobile action buttons — visible only on small screens */} +
{canPreview && (