Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Batch 1 - Quick Wins: - F1: Evaluation progress indicator with touch tracking in sticky status bar - F2: Export filtering results as CSV with dynamic AI column flattening - F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure) Batch 2 - Jury Experience: - F4: Countdown timer component with urgency colors + email reminder service with cron endpoint - F5: Conflict of interest declaration system (dialog, admin management, review workflow) Batch 3 - Admin & AI Enhancements: - F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording - F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns - F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking) Batch 4 - Form Flexibility & Applicant Portal: - F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility) - F10: Applicant portal (status timeline, per-round documents, mentor messaging) Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
@@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
{project.roundId && stats && stats.totalEvaluations > 0 && (
|
||||
<EvaluationSummaryCard
|
||||
projectId={projectId}
|
||||
roundId={project.roundId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ import {
|
||||
AlertCircle,
|
||||
Layers,
|
||||
FolderOpen,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
@@ -75,6 +77,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { truncate } from '@/lib/utils'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
@@ -346,6 +349,77 @@ export default function ProjectsPage() {
|
||||
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
|
||||
: 0
|
||||
|
||||
// Bulk selection state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||
|
||||
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`)
|
||||
setSelectedIds(new Set())
|
||||
setBulkStatus('')
|
||||
setBulkConfirmOpen(false)
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update projects')
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!data) return
|
||||
const allVisible = data.projects.map((p) => p.id)
|
||||
const allSelected = allVisible.every((id) => selectedIds.has(id))
|
||||
if (allSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
allVisible.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
allVisible.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkApply = () => {
|
||||
if (!bulkStatus || selectedIds.size === 0) return
|
||||
setBulkConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkConfirm = () => {
|
||||
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
|
||||
bulkUpdateStatus.mutate({
|
||||
ids: Array.from(selectedIds),
|
||||
roundId: filters.roundId,
|
||||
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
|
||||
})
|
||||
}
|
||||
|
||||
// Determine if all visible projects are selected
|
||||
const allVisibleSelected = data
|
||||
? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id))
|
||||
: false
|
||||
const someVisibleSelected = data
|
||||
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
|
||||
: false
|
||||
|
||||
const deleteProject = trpc.project.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project deleted successfully')
|
||||
@@ -468,6 +542,15 @@ export default function ProjectsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{filters.roundId && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all projects"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Files</TableHead>
|
||||
@@ -484,6 +567,16 @@ export default function ProjectsPage() {
|
||||
key={project.id}
|
||||
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
|
||||
>
|
||||
{filters.roundId && (
|
||||
<TableCell className="relative z-10">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => handleToggleSelect(project.id)}
|
||||
aria-label={`Select ${project.title}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
@@ -577,14 +670,23 @@ export default function ProjectsPage() {
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{data.projects.map((project) => (
|
||||
<div key={project.id} className="relative">
|
||||
{filters.roundId && (
|
||||
<div className="absolute left-3 top-4 z-10">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => handleToggleSelect(project.id)}
|
||||
aria-label={`Select ${project.title}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="transition-colors hover:bg-muted/50">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
size="md"
|
||||
@@ -627,6 +729,7 @@ export default function ProjectsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -641,6 +744,93 @@ export default function ProjectsPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Bulk Action Floating Toolbar */}
|
||||
{selectedIds.size > 0 && filters.roundId && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
|
||||
<Card className="border-2 shadow-lg">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<Badge variant="secondary" className="shrink-0 text-sm">
|
||||
{selectedIds.size} selected
|
||||
</Badge>
|
||||
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Set status..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s.replace('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkApply}
|
||||
disabled={!bulkStatus || bulkUpdateStatus.isPending}
|
||||
>
|
||||
{bulkUpdateStatus.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set())
|
||||
setBulkStatus('')
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Status Update Confirmation Dialog */}
|
||||
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
You are about to change the status of{' '}
|
||||
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
|
||||
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
|
||||
</p>
|
||||
{bulkStatus === 'REJECTED' && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-sm">
|
||||
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleBulkConfirm}
|
||||
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||
disabled={bulkUpdateStatus.isPending}
|
||||
>
|
||||
{bulkUpdateStatus.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
|
||||
404
src/app/(admin)/admin/rounds/[id]/coi/page.tsx
Normal file
404
src/app/(admin)/admin/rounds/[id]/coi/page.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Notification - removed from schema, feature not implemented */}
|
||||
{/* Upload Deadline Policy */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
|
||||
<CardDescription>
|
||||
Control how file uploads are handled after the round starts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
|
||||
onValueChange={(value) =>
|
||||
setRoundSettings((prev) => ({
|
||||
...prev,
|
||||
uploadDeadlinePolicy: value || undefined,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Default (no restriction)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">
|
||||
Default - No restriction
|
||||
</SelectItem>
|
||||
<SelectItem value="BLOCK">
|
||||
Block uploads after round starts
|
||||
</SelectItem>
|
||||
<SelectItem value="ALLOW_LATE">
|
||||
Allow late uploads (marked as late)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When set to “Block”, applicants cannot upload files after the voting start date.
|
||||
When set to “Allow late”, uploads are accepted but flagged as late submissions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Evaluation Criteria */}
|
||||
<Card>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -109,6 +110,41 @@ export default function FilteringResultsPage({
|
||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
||||
|
||||
const exportResults = trpc.export.filteringResults.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleExport = async () => {
|
||||
const result = await exportResults.refetch()
|
||||
if (result.data) {
|
||||
const { data: rows, columns } = result.data
|
||||
|
||||
const csvContent = [
|
||||
columns.join(','),
|
||||
...rows.map((row) =>
|
||||
columns
|
||||
.map((col) => {
|
||||
const value = row[col as keyof typeof row]
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value ?? ''
|
||||
})
|
||||
.join(',')
|
||||
),
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const next = new Set(expandedRows)
|
||||
if (next.has(id)) next.delete(id)
|
||||
@@ -166,13 +202,27 @@ export default function FilteringResultsPage({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Results
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and override filtering outcomes
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Results
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and override filtering outcomes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={exportResults.isFetching}
|
||||
>
|
||||
{exportResults.isFetching ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Outcome Filter Tabs */}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
AlertTriangle,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
@@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
|
||||
// AI summary bulk generation
|
||||
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.errors.length > 0) {
|
||||
toast.warning(
|
||||
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
|
||||
)
|
||||
} else {
|
||||
toast.success(`Generated ${data.generated} AI summaries successfully`)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to generate AI summaries')
|
||||
},
|
||||
})
|
||||
|
||||
// Set active job from latest job on load
|
||||
useEffect(() => {
|
||||
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
|
||||
@@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
|
||||
disabled={bulkSummaries.isPending}
|
||||
>
|
||||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user