COI gate + admin review, mobile file viewer fixes for iOS
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 */}
|
||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||
|
||||
{/* Conflict of Interest Declarations */}
|
||||
<COIReviewSection roundId={roundId} />
|
||||
|
||||
{/* Unassigned Queue */}
|
||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||
|
||||
@@ -3264,3 +3269,148 @@ function EvaluationCriteriaEditor({ roundId }: { roundId: string }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Conflict of Interest Declarations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
|
||||
{conflictCount > 0 && (
|
||||
<> — <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
|
||||
)}
|
||||
{unreviewedCount > 0 && (
|
||||
<> ({unreviewedCount} pending review)</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
|
||||
<span>Juror</span>
|
||||
<span>Project</span>
|
||||
<span>Conflict</span>
|
||||
<span>Type</span>
|
||||
<span>Action</span>
|
||||
</div>
|
||||
{declarations?.map((coi: any, idx: number) => (
|
||||
<div
|
||||
key={coi.id}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
|
||||
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
|
||||
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
|
||||
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.hasConflict
|
||||
? 'bg-red-50 text-red-700 border-red-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
)}
|
||||
>
|
||||
{coi.hasConflict ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
|
||||
</span>
|
||||
{coi.hasConflict ? (
|
||||
coi.reviewedAt ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] justify-center',
|
||||
coi.reviewAction === 'cleared'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: coi.reviewAction === 'reassigned'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200',
|
||||
)}
|
||||
>
|
||||
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
|
||||
</Badge>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Review
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
|
||||
Clear — no real conflict
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
|
||||
Reassign to another juror
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
|
||||
disabled={reviewMutation.isPending}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
|
||||
Note — keep as is
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user