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 && (