405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, use, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import {
|
|
ArrowLeft,
|
|
ShieldAlert,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
MoreHorizontal,
|
|
ShieldCheck,
|
|
UserX,
|
|
StickyNote,
|
|
Loader2,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>
|
|
}
|
|
|
|
function COIManagementContent({ roundId }: { roundId: string }) {
|
|
const [conflictsOnly, setConflictsOnly] = useState(false)
|
|
|
|
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
|
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
|
|
roundId,
|
|
hasConflictOnly: conflictsOnly || undefined,
|
|
})
|
|
|
|
const utils = trpc.useUtils()
|
|
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
|
|
onSuccess: (data) => {
|
|
utils.evaluation.listCOIByRound.invalidate({ roundId })
|
|
toast.success(`COI marked as "${data.reviewAction}"`)
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || 'Failed to review COI')
|
|
},
|
|
})
|
|
|
|
if (loadingRound || loadingCOI) {
|
|
return <COISkeleton />
|
|
}
|
|
|
|
if (!round) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
|
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
|
<p className="mt-2 font-medium">Round Not Found</p>
|
|
<Button asChild className="mt-4">
|
|
<Link href="/admin/rounds">Back to Rounds</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
|
|
const totalCount = coiList?.length ?? 0
|
|
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
|
|
|
|
const getReviewBadge = (reviewAction: string | null) => {
|
|
switch (reviewAction) {
|
|
case 'cleared':
|
|
return (
|
|
<Badge variant="default" className="bg-green-600">
|
|
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
Cleared
|
|
</Badge>
|
|
)
|
|
case 'reassigned':
|
|
return (
|
|
<Badge variant="default" className="bg-blue-600">
|
|
<UserX className="mr-1 h-3 w-3" />
|
|
Reassigned
|
|
</Badge>
|
|
)
|
|
case 'noted':
|
|
return (
|
|
<Badge variant="secondary">
|
|
<StickyNote className="mr-1 h-3 w-3" />
|
|
Noted
|
|
</Badge>
|
|
)
|
|
default:
|
|
return (
|
|
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
|
Pending Review
|
|
</Badge>
|
|
)
|
|
}
|
|
}
|
|
|
|
const getConflictTypeBadge = (type: string | null) => {
|
|
switch (type) {
|
|
case 'financial':
|
|
return <Badge variant="destructive">Financial</Badge>
|
|
case 'personal':
|
|
return <Badge variant="secondary">Personal</Badge>
|
|
case 'organizational':
|
|
return <Badge variant="outline">Organizational</Badge>
|
|
case 'other':
|
|
return <Badge variant="outline">Other</Badge>
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" asChild className="-ml-4">
|
|
<Link href={`/admin/rounds/${roundId}`}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Round
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
|
{round.program.name}
|
|
</Link>
|
|
<span>/</span>
|
|
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
|
|
{round.name}
|
|
</Link>
|
|
</div>
|
|
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
|
<ShieldAlert className="h-6 w-6" />
|
|
Conflict of Interest Declarations
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
|
|
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{totalCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
|
|
<AlertCircle className="h-4 w-4 text-amber-500" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
|
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{reviewedCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* COI Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">Declarations</CardTitle>
|
|
<CardDescription>
|
|
Review and manage conflict of interest declarations from jury members
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="conflicts-only"
|
|
checked={conflictsOnly}
|
|
onCheckedChange={setConflictsOnly}
|
|
/>
|
|
<Label htmlFor="conflicts-only" className="text-sm">
|
|
Conflicts only
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{coiList && coiList.length > 0 ? (
|
|
<div className="rounded-lg border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Project</TableHead>
|
|
<TableHead>Juror</TableHead>
|
|
<TableHead>Conflict</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead>Description</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="w-12">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{coiList.map((coi) => (
|
|
<TableRow key={coi.id}>
|
|
<TableCell className="font-medium max-w-[200px] truncate">
|
|
{coi.assignment.project.title}
|
|
</TableCell>
|
|
<TableCell>
|
|
{coi.user.name || coi.user.email}
|
|
</TableCell>
|
|
<TableCell>
|
|
{coi.hasConflict ? (
|
|
<Badge variant="destructive">Yes</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-green-600 border-green-300">
|
|
No
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
|
|
</TableCell>
|
|
<TableCell className="max-w-[200px]">
|
|
{coi.description ? (
|
|
<span className="text-sm text-muted-foreground truncate block">
|
|
{coi.description}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">-</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{coi.hasConflict ? (
|
|
<div className="space-y-1">
|
|
{getReviewBadge(coi.reviewAction)}
|
|
{coi.reviewedBy && (
|
|
<p className="text-xs text-muted-foreground">
|
|
by {coi.reviewedBy.name || coi.reviewedBy.email}
|
|
{coi.reviewedAt && (
|
|
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">N/A</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{coi.hasConflict && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={reviewCOI.isPending}
|
|
>
|
|
{reviewCOI.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
reviewCOI.mutate({
|
|
id: coi.id,
|
|
reviewAction: 'cleared',
|
|
})
|
|
}
|
|
>
|
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
|
Clear
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
reviewCOI.mutate({
|
|
id: coi.id,
|
|
reviewAction: 'reassigned',
|
|
})
|
|
}
|
|
>
|
|
<UserX className="mr-2 h-4 w-4" />
|
|
Reassign
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
reviewCOI.mutate({
|
|
id: coi.id,
|
|
reviewAction: 'noted',
|
|
})
|
|
}
|
|
>
|
|
<StickyNote className="mr-2 h-4 w-4" />
|
|
Note
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
|
|
<p className="mt-2 font-medium">No Declarations Yet</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{conflictsOnly
|
|
? 'No conflicts of interest have been declared for this round'
|
|
: 'No jury members have submitted COI declarations for this round yet'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function COISkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-9 w-36" />
|
|
|
|
<div className="space-y-1">
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-8 w-80" />
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="pb-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-16" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-5 w-48" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-48 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function COIManagementPage({ params }: PageProps) {
|
|
const { id } = use(params)
|
|
|
|
return (
|
|
<Suspense fallback={<COISkeleton />}>
|
|
<COIManagementContent roundId={id} />
|
|
</Suspense>
|
|
)
|
|
}
|