From 8d28104d510b59f5f8679ba855fb069eb0e9a9d7 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Feb 2026 14:34:27 +0100 Subject: [PATCH] COI gate + admin review, mobile file viewer fixes for iOS - Integrate COI declaration dialog into jury evaluate page (blocks evaluation until declared) - Add COI review section to admin round page with clear/reassign/note actions - Fix mobile: remove inline preview (viewport too small), add labeled buttons - Fix iOS: open-in-new-tab uses synchronous window.open to avoid popup blocker - Fix iOS: download falls back to direct link if fetch+blob fails (CORS/Safari) Co-Authored-By: Claude Opus 4.6 --- .../(admin)/admin/rounds/[roundId]/page.tsx | 150 ++++++++++++++++++ .../projects/[projectId]/evaluate/page.tsx | 91 ++++++++++- src/components/shared/file-viewer.tsx | 101 +++++++----- 3 files changed, 298 insertions(+), 44 deletions(-) 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'} + + ) : ( + + + + + + 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 ( +
+
+ +
+

+ Evaluate Project +

+

{project.title}

+
+
+ { + setCOICompleted(true) + setCOIHasConflict(hasConflict) + }} + /> +
+ ) + } + + // COI conflict declared — block evaluation + if (coiRequired && coiConflict) { + return ( +
+
+ +
+

+ 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. +

+ +
+
+
+
+ ) + } + 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 && ( - - )} - - + +
- {/* 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} ) }