diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
index 05be48c..fbdcd37 100644
--- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx
@@ -82,6 +82,8 @@ import {
ChevronsUpDown,
Search,
MoreHorizontal,
+ ShieldAlert,
+ Eye,
} from 'lucide-react'
import {
Command,
@@ -1755,6 +1757,9 @@ export default function RoundDetailPage() {
{/* Individual Assignments Table */}
+ {/* Conflict of Interest Declarations */}
+
+
{/* Unassigned Queue */}
@@ -3264,3 +3269,148 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
)
}
+
+// ── COI Review Section ────────────────────────────────────────────────────
+
+function COIReviewSection({ roundId }: { roundId: string }) {
+ const utils = trpc.useUtils()
+ const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
+ { roundId },
+ { refetchInterval: 15_000 },
+ )
+
+ const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
+ onSuccess: () => {
+ utils.evaluation.listCOIByStage.invalidate({ roundId })
+ toast.success('COI review updated')
+ },
+ onError: (err) => toast.error(err.message),
+ })
+
+ // Don't show section if no declarations
+ if (!isLoading && (!declarations || declarations.length === 0)) {
+ return null
+ }
+
+ const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
+ const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
+
+ return (
+
+
+
+
+
+
+ Conflict of Interest Declarations
+
+
+ {declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
+ {conflictCount > 0 && (
+ <> — {conflictCount} conflict{conflictCount !== 1 ? 's' : ''} >
+ )}
+ {unreviewedCount > 0 && (
+ <> ({unreviewedCount} pending review)>
+ )}
+
+
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => )}
+
+ ) : (
+
+
+ Juror
+ Project
+ Conflict
+ Type
+ Action
+
+ {declarations?.map((coi: any, idx: number) => (
+
+ {coi.user?.name || coi.user?.email || 'Unknown'}
+ {coi.assignment?.project?.title || 'Unknown'}
+
+ {coi.hasConflict ? 'Yes' : 'No'}
+
+
+ {coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
+
+ {coi.hasConflict ? (
+ coi.reviewedAt ? (
+
+ {coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
+
+ ) : (
+
+
+
+
+ Review
+
+
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Clear — no real conflict
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Reassign to another juror
+
+ reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
+ disabled={reviewMutation.isPending}
+ >
+
+ Note — keep as is
+
+
+
+ )
+ ) : (
+ —
+ )}
+
+ ))}
+
+ )}
+
+
+ )
+}
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 0832e02..361561c 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
@@ -15,7 +15,8 @@ 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 { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
+import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
@@ -68,6 +69,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id }
)
+ // COI (Conflict of Interest) check
+ const { data: coiStatus, isLoading: coiLoading } = trpc.evaluation.getCOIStatus.useQuery(
+ { assignmentId: myAssignment?.id ?? '' },
+ { enabled: !!myAssignment?.id }
+ )
+ const [coiCompleted, setCOICompleted] = useState(false)
+ const [coiHasConflict, setCOIHasConflict] = useState(false)
+
// Fetch the active evaluation form for this round
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
@@ -385,6 +394,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
+ // COI config
+ const coiRequired = evalConfig?.coiRequired ?? true
+
+ // Determine COI state: declared via server or just completed in this session
+ const coiDeclared = coiCompleted || coiStatus !== undefined
+ const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
+
// Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE'
@@ -421,6 +437,79 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
+ // COI gate: if COI is required, not yet declared, and we have an assignment
+ if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
+ return (
+
+
+
+
+
+ Back to Project
+
+
+
+
+ Evaluate Project
+
+
{project.title}
+
+
+
{
+ setCOICompleted(true)
+ setCOIHasConflict(hasConflict)
+ }}
+ />
+
+ )
+ }
+
+ // COI conflict declared — block evaluation
+ if (coiRequired && coiConflict) {
+ return (
+
+
+
+
+
+ Back to Project
+
+
+
+
+ Evaluate Project
+
+
{project.title}
+
+
+
+
+
+
+
+
+
Conflict of Interest Declared
+
+ You declared a conflict of interest for this project. An administrator will
+ review your declaration. You cannot evaluate this project while the conflict
+ is under review.
+
+
+
+ Back to Round
+
+
+
+
+
+
+ )
+ }
+
return (
diff --git a/src/components/shared/file-viewer.tsx b/src/components/shared/file-viewer.tsx
index 0303901..4f12f60 100644
--- a/src/components/shared/file-viewer.tsx
+++ b/src/components/shared/file-viewer.tsx
@@ -333,36 +333,16 @@ function FileItem({ file }: { file: ProjectFile }) {
- {/* Mobile action buttons — visible only on small screens */}
+ {/* Mobile action buttons — no inline preview on mobile, just open + download */}
- {canPreview && (
-
setShowPreview(!showPreview)}
- >
- {showPreview ? (
- <>
-
- Close
- >
- ) : (
- <>
-
- Preview
- >
- )}
-
- )}
-
-
+
+
- {/* Preview area */}
+ {/* Preview area — desktop only */}
{showPreview && (
-
+
{isLoadingUrl ? (
@@ -561,7 +541,7 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds
)
}
-function FileOpenButton({ file }: { file: ProjectFile }) {
+function FileOpenButton({ file, className, label }: { file: ProjectFile; className?: string; label?: string }) {
const [loading, setLoading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
@@ -571,13 +551,25 @@ function FileOpenButton({ file }: { file: ProjectFile }) {
const handleOpen = async () => {
setLoading(true)
+ // Open the blank window immediately in the click handler (synchronous)
+ // so iOS Safari doesn't block it as a popup
+ const newWindow = window.open('about:blank', '_blank')
try {
const result = await refetch()
if (result.data?.url) {
- window.open(result.data.url, '_blank')
+ if (newWindow) {
+ newWindow.location.href = result.data.url
+ } else {
+ // Fallback: if popup was still blocked, navigate current tab
+ window.location.href = result.data.url
+ }
+ } else {
+ newWindow?.close()
}
} catch (error) {
console.error('Failed to get URL:', error)
+ newWindow?.close()
+ toast.error('Failed to open file')
} finally {
setLoading(false)
}
@@ -590,17 +582,19 @@ function FileOpenButton({ file }: { file: ProjectFile }) {
onClick={handleOpen}
disabled={loading}
aria-label="Open file in new tab"
+ className={className}
>
{loading ? (
-
+
) : (
-
+
)}
+ {label}
)
}
-function FileDownloadButton({ file }: { file: ProjectFile }) {
+function FileDownloadButton({ file, className, label }: { file: ProjectFile; className?: string; label?: string }) {
const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery(
@@ -613,17 +607,36 @@ function FileDownloadButton({ file }: { file: ProjectFile }) {
try {
const result = await refetch()
if (result.data?.url) {
- // Fetch as blob to force download (cross-origin URLs ignore
)
- const response = await fetch(result.data.url)
- const blob = await response.blob()
- const blobUrl = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = blobUrl
- link.download = file.fileName
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(blobUrl)
+ // Try fetch+blob first (works on desktop, some mobile browsers)
+ try {
+ const response = await fetch(result.data.url)
+ if (!response.ok) throw new Error('fetch failed')
+ const blob = await response.blob()
+ const blobUrl = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = blobUrl
+ link.download = file.fileName
+ // iOS Safari needs the link in the DOM
+ link.style.display = 'none'
+ document.body.appendChild(link)
+ link.click()
+ // Delay cleanup for iOS
+ setTimeout(() => {
+ document.body.removeChild(link)
+ URL.revokeObjectURL(blobUrl)
+ }, 100)
+ } catch {
+ // Fallback: open in new tab (iOS Safari often blocks blob downloads)
+ // Open synchronously via a link click to avoid popup blockers
+ const link = document.createElement('a')
+ link.href = result.data.url
+ link.target = '_blank'
+ link.rel = 'noopener noreferrer'
+ link.style.display = 'none'
+ document.body.appendChild(link)
+ link.click()
+ setTimeout(() => document.body.removeChild(link), 100)
+ }
}
} catch (error) {
console.error('Failed to download file:', error)
@@ -640,12 +653,14 @@ function FileDownloadButton({ file }: { file: ProjectFile }) {
onClick={handleDownload}
disabled={downloading}
aria-label="Download file"
+ className={className}
>
{downloading ? (
-
+
) : (
-
+
)}
+ {label}
)
}